mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-02 22:57:00 +00:00
Chore/tech debts (#1536)
* build(deps): bump the npm_and_yarn group across 5 directories with 22 updates Bumps the npm_and_yarn group with 18 updates in the / directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.5.1` | `1.12.0` | | [dompurify](https://github.com/cure53/DOMPurify) | `3.0.6` | `3.2.4` | | [formidable](https://github.com/node-formidable/formidable) | `3.5.1` | `3.5.4` | | [next](https://github.com/vercel/next.js) | `13.4.12` | `14.2.35` | | [next-auth](https://github.com/nextauthjs/next-auth) | `4.22.1` | `4.24.12` | | [playwright](https://github.com/microsoft/playwright) | `1.55.0` | `1.55.1` | | [@mozilla/readability](https://github.com/mozilla/readability) | `0.4.4` | `0.6.0` | | [ai](https://github.com/vercel/ai) | `4.3.9` | `5.0.52` | | [nodemailer](https://github.com/nodemailer/nodemailer) | `6.9.3` | `7.0.11` | | [brace-expansion](https://github.com/juliangruber/brace-expansion) | `1.1.11` | `1.1.12` | | [braces](https://github.com/micromatch/braces) | `3.0.2` | `3.0.3` | | [form-data](https://github.com/form-data/form-data) | `3.0.3` | `3.0.4` | | [js-yaml](https://github.com/nodeca/js-yaml) | `3.14.1` | `3.14.2` | | [micromatch](https://github.com/micromatch/micromatch) | `4.0.5` | `4.0.8` | | [min-document](https://github.com/Raynos/min-document) | `2.19.0` | `2.19.2` | | [nanoid](https://github.com/ai/nanoid) | `3.3.6` | `3.3.8` | | [node-forge](https://github.com/digitalbazaar/forge) | `1.3.1` | `1.3.3` | | [tar](https://github.com/isaacs/node-tar) | `6.1.13` | `6.2.1` | Bumps the npm_and_yarn group with 1 update in the /apps/web directory: [next](https://github.com/vercel/next.js). Bumps the npm_and_yarn group with 2 updates in the /apps/worker directory: [@mozilla/readability](https://github.com/mozilla/readability) and [ai](https://github.com/vercel/ai). Bumps the npm_and_yarn group with 1 update in the /packages/lib directory: [nodemailer](https://github.com/nodemailer/nodemailer). Bumps the npm_and_yarn group with 1 update in the /packages/router directory: [next](https://github.com/vercel/next.js). Updates `axios` from 1.5.1 to 1.12.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.5.1...v1.12.0) Updates `dompurify` from 3.0.6 to 3.2.4 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.0.6...3.2.4) Updates `formidable` from 3.5.1 to 3.5.4 - [Release notes](https://github.com/node-formidable/formidable/releases) - [Changelog](https://github.com/node-formidable/formidable/blob/master/CHANGELOG.md) - [Commits](https://github.com/node-formidable/formidable/commits) Updates `next` from 13.4.12 to 14.2.35 - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35) Updates `next-auth` from 4.22.1 to 4.24.12 - [Release notes](https://github.com/nextauthjs/next-auth/releases) - [Commits](https://github.com/nextauthjs/next-auth/compare/next-auth@4.22.1...next-auth@4.24.12) Updates `playwright` from 1.55.0 to 1.55.1 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.55.0...v1.55.1) Updates `postcss` from 8.4.26 to 8.5.3 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.4.26...8.5.3) Updates `@mozilla/readability` from 0.4.4 to 0.6.0 - [Changelog](https://github.com/mozilla/readability/blob/main/CHANGELOG.md) - [Commits](https://github.com/mozilla/readability/compare/0.4.4...0.6.0) Updates `ai` from 4.3.9 to 5.0.52 - [Release notes](https://github.com/vercel/ai/releases) - [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md) - [Commits](https://github.com/vercel/ai/compare/ai@4.3.9...ai@5.0.52) Updates `nodemailer` from 6.9.3 to 7.0.11 - [Release notes](https://github.com/nodemailer/nodemailer/releases) - [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.3...v7.0.11) Updates `@babel/runtime` from 7.21.5 to 7.27.0 - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-runtime) Updates `brace-expansion` from 1.1.11 to 1.1.12 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12) Updates `braces` from 3.0.2 to 3.0.3 - [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3) Updates `follow-redirects` from 1.15.3 to 1.15.11 - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.11) Updates `form-data` from 3.0.3 to 3.0.4 - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v3.0.3...v3.0.4) Updates `jose` from 4.14.4 to 4.15.9 - [Release notes](https://github.com/panva/jose/releases) - [Changelog](https://github.com/panva/jose/blob/v4.15.9/CHANGELOG.md) - [Commits](https://github.com/panva/jose/compare/v4.14.4...v4.15.9) Updates `js-yaml` from 3.14.1 to 3.14.2 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2) Updates `micromatch` from 4.0.5 to 4.0.8 - [Release notes](https://github.com/micromatch/micromatch/releases) - [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8) Updates `min-document` from 2.19.0 to 2.19.2 - [Commits](https://github.com/Raynos/min-document/compare/v2.19.0...v2.19.2) Updates `nanoid` from 3.3.6 to 3.3.8 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.6...3.3.8) Updates `node-forge` from 1.3.1 to 1.3.3 - [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md) - [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.1...v1.3.3) Updates `tar` from 6.1.13 to 6.2.1 - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v6.1.13...v6.2.1) Updates `next` from 13.4.12 to 14.2.35 - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35) Updates `@mozilla/readability` from 0.4.4 to 0.6.0 - [Changelog](https://github.com/mozilla/readability/blob/main/CHANGELOG.md) - [Commits](https://github.com/mozilla/readability/compare/0.4.4...0.6.0) Updates `ai` from 4.3.19 to 5.0.113 - [Release notes](https://github.com/vercel/ai/releases) - [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md) - [Commits](https://github.com/vercel/ai/compare/ai@4.3.9...ai@5.0.52) Updates `nodemailer` from 6.10.1 to 7.0.11 - [Release notes](https://github.com/nodemailer/nodemailer/releases) - [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.3...v7.0.11) Updates `next` from 13.4.12 to 14.2.35 - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35) --- updated-dependencies: - dependency-name: axios dependency-version: 1.12.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: dompurify dependency-version: 3.2.4 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: formidable dependency-version: 3.5.4 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: next dependency-version: 14.2.35 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: next-auth dependency-version: 4.24.12 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: playwright dependency-version: 1.55.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: postcss dependency-version: 8.5.3 dependency-type: direct:development dependency-group: npm_and_yarn - dependency-name: "@mozilla/readability" dependency-version: 0.6.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: ai dependency-version: 5.0.52 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: nodemailer dependency-version: 7.0.11 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: "@babel/runtime" dependency-version: 7.27.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: brace-expansion dependency-version: 1.1.12 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: braces dependency-version: 3.0.3 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: follow-redirects dependency-version: 1.15.11 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: form-data dependency-version: 3.0.4 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: jose dependency-version: 4.15.9 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: js-yaml dependency-version: 3.14.2 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: micromatch dependency-version: 4.0.8 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: min-document dependency-version: 2.19.2 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: nanoid dependency-version: 3.3.8 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: node-forge dependency-version: 1.3.3 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: tar dependency-version: 6.2.1 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: next dependency-version: 14.2.35 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: "@mozilla/readability" dependency-version: 0.6.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: ai dependency-version: 5.0.113 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: nodemailer dependency-version: 7.0.11 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: next dependency-version: 14.2.35 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] <support@github.com> * bug fixes and improvements * always show navbar in reader view * bug fix and small performance improvement * minor fix * Refactor link selection management and bulk actions - Replaced the use of selectedLinks with selectedIds in the link store for better performance and clarity. - Updated LinkListOptions, BulkDeleteLinksModal, and BulkEditLinksModal components to utilize the new selection management. - Modified LinkCard, LinkMasonry, and LinkList components to handle selection state through props. - Enhanced updateLinks API to support bulk updates with improved tag management. - Cleaned up unused imports and code related to previous selection methods. * move refetching logic to Links component * move disableDraggable and user hook out of each card to improve efficiency * cleaner code * memoize components and increase performance * fix: update announcement links to use the correct domain * feat: add favicon field to Link model + update packages + bug fix * feat: implement favicon fetching API and update Link model for favicon support * feat: add priority attribute to Image components in Sidebar * Refactor pages to use consistent layout handling (yes, I forgot to do that until now :P) * bump version * Refactor setting pages to use consistent layout handling * upgrade yarn to 4.12.0 * fix DnD bug * Enhance announcement handling by adding support for announcement messages * slimmed down the docker image size * update Node and yarn versions in playwright tests workflow * small fix * fix attempt 2 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -5,3 +5,10 @@ pgdata
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
README.md
|
||||
.yarn/install-state.gz
|
||||
./apps/mobile
|
||||
**/.next/cache
|
||||
**/.next/cache/**
|
||||
data
|
||||
data.ms
|
||||
.git
|
||||
14
.github/workflows/playwright-tests.yml
vendored
14
.github/workflows/playwright-tests.yml
vendored
@@ -61,12 +61,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
- name: Use Node.js and Enable Yarn 4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: 'yarn'
|
||||
node-version: "20"
|
||||
|
||||
- name: Enable Yarn 4
|
||||
run: |
|
||||
sudo rm -f /usr/bin/yarn /usr/bin/yarnpkg || true
|
||||
corepack enable
|
||||
corepack prepare yarn@4.12.0 --activate
|
||||
yarn --version
|
||||
|
||||
- name: Initialize PostgreSQL
|
||||
run: |
|
||||
echo "Initializing Databases"
|
||||
@@ -74,7 +80,7 @@ jobs:
|
||||
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
|
||||
|
||||
- name: Install packages
|
||||
run: yarn install -y
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Cache playwright dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@@ -48,9 +49,10 @@ certificates
|
||||
|
||||
# generated files and folders
|
||||
/data
|
||||
/data.ms
|
||||
meilisearch
|
||||
.idea
|
||||
prisma/dev.db
|
||||
data.ms
|
||||
.turbo
|
||||
|
||||
service-account-file.json
|
||||
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
17
Dockerfile
17
Dockerfile
@@ -10,7 +10,11 @@ RUN set -eux && cargo install --locked monolith
|
||||
# Purpose: Compiles the frontend and
|
||||
# Notes:
|
||||
# - Nothing extra should be left here. All commands should cleanup
|
||||
FROM node:22.14-bullseye-slim AS main-app
|
||||
FROM node:20.19.6-bullseye-slim AS main-app
|
||||
|
||||
ENV YARN_HTTP_TIMEOUT=10000000
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -18,6 +22,12 @@ RUN mkdir /data
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY ./.yarnrc.yml ./
|
||||
|
||||
COPY ./.yarn ./.yarn
|
||||
|
||||
COPY ./apps/web/package.json ./apps/web/playwright.config.ts ./apps/web/
|
||||
|
||||
COPY ./apps/worker/package.json ./apps/worker/
|
||||
@@ -28,7 +38,7 @@ COPY ./yarn.lock ./package.json ./
|
||||
|
||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
|
||||
set -eux && \
|
||||
yarn install --network-timeout 10000000 && \
|
||||
yarn workspaces focus linkwarden @linkwarden/web @linkwarden/worker && \
|
||||
# Install curl for healthcheck, and ca-certificates to prevent monolith from failing to retrieve resources due to invalid certificates
|
||||
apt-get update && \
|
||||
apt-get install -yqq --no-install-recommends curl ca-certificates && \
|
||||
@@ -46,7 +56,8 @@ RUN set -eux && \
|
||||
COPY . .
|
||||
|
||||
RUN yarn prisma:generate && \
|
||||
yarn web:build
|
||||
yarn web:build && \
|
||||
rm -rf apps/web/.next/cache
|
||||
|
||||
HEALTHCHECK --interval=30s \
|
||||
--timeout=5s \
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
node-linker=hoisted
|
||||
@@ -76,7 +76,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~52.0.2",
|
||||
|
||||
@@ -9,24 +9,39 @@ type Props = {
|
||||
|
||||
export default function Announcement({ toggleAnnouncementBar }: Props) {
|
||||
const announcementId = localStorage.getItem("announcementId");
|
||||
const announcementMessage = localStorage.getItem("announcementMessage");
|
||||
|
||||
return (
|
||||
<div className="fixed mx-auto bottom-20 sm:bottom-10 w-full pointer-events-none p-5 z-30">
|
||||
<div className="mx-auto pointer-events-auto p-2 flex justify-between gap-2 items-center border border-primary shadow-xl rounded-xl bg-base-300 backdrop-blur-sm bg-opacity-80 max-w-md">
|
||||
<i className="bi-stars text-xl text-yellow-600 dark:text-yellow-500"></i>
|
||||
<p className="w-4/5 text-center text-sm sm:text-base">
|
||||
<Trans
|
||||
i18nKey="new_version_announcement"
|
||||
values={{ version: announcementId }}
|
||||
components={[
|
||||
<Link
|
||||
href={`https://blog.linkwarden.app/releases/${announcementId}`}
|
||||
target="_blank"
|
||||
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||
key={0}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
{announcementId ? (
|
||||
<Trans
|
||||
i18nKey="new_version_announcement"
|
||||
values={{ version: announcementId }}
|
||||
components={[
|
||||
<Link
|
||||
href={`https://linkwarden.app/blog/releases/${announcementId}`}
|
||||
target="_blank"
|
||||
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||
key={0}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
) : announcementMessage ? (
|
||||
<Trans
|
||||
i18nKey={announcementMessage}
|
||||
components={[
|
||||
<Link
|
||||
href={`https://linkwarden.app/blog`}
|
||||
target="_blank"
|
||||
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||
key={0}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
) : undefined}
|
||||
</p>
|
||||
<Button variant="ghost" size="icon" onClick={toggleAnnouncementBar}>
|
||||
<i className="bi-x text-xl"></i>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { ReactNode } from "react";
|
||||
@@ -52,7 +51,11 @@ export default function CenteredForm({
|
||||
values={{ date: new Date().getFullYear() }}
|
||||
i18nKey="all_rights_reserved"
|
||||
components={[
|
||||
<Link href="https://linkwarden.app" className="font-semibold" />,
|
||||
<Link
|
||||
href="https://linkwarden.app"
|
||||
className="font-semibold"
|
||||
key="linkwarden-website-key"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
@@ -36,11 +36,10 @@ const CollectionListing = () => {
|
||||
const updateCollection = useUpdateCollection();
|
||||
const { data: collections = [], isLoading } = useCollections();
|
||||
|
||||
const { data: user, refetch } = useUser();
|
||||
const { data: user } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const router = useRouter();
|
||||
const currentPath = router.asPath;
|
||||
|
||||
const [tree, setTree] = useState<TreeData | undefined>();
|
||||
|
||||
@@ -53,7 +52,7 @@ const CollectionListing = () => {
|
||||
user?.collectionOrder
|
||||
);
|
||||
} else return undefined;
|
||||
}, [collections, user, router]);
|
||||
}, [collections, user]);
|
||||
|
||||
useEffect(() => {
|
||||
setTree(initialTree);
|
||||
@@ -281,7 +280,7 @@ const CollectionListing = () => {
|
||||
<Tree
|
||||
tree={tree}
|
||||
renderItem={(itemProps) =>
|
||||
renderItem({ ...itemProps }, currentPath, droppableActive)
|
||||
renderItem({ ...itemProps }, router.asPath, droppableActive)
|
||||
}
|
||||
onExpand={onExpand}
|
||||
onCollapse={onCollapse}
|
||||
|
||||
@@ -27,6 +27,7 @@ import LinkPin from "./LinkViews/LinkComponents/LinkPin";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
export function DashboardLinks({
|
||||
links,
|
||||
@@ -63,10 +64,13 @@ type Props = {
|
||||
};
|
||||
|
||||
export function Card({ link, editMode, dashboardType }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `${link.id}-${dashboardType}`,
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
dashboardType,
|
||||
},
|
||||
});
|
||||
@@ -163,6 +167,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className={`bg-gray-50 h-40 bg-opacity-80`}></div>
|
||||
@@ -206,7 +211,11 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
|
||||
{show.collection && !isPublicRoute && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
@@ -220,7 +229,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
t={t}
|
||||
linkModal={linkModal}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
|
||||
@@ -15,9 +15,11 @@ import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import toast from "react-hot-toast";
|
||||
import { useUpdateLink } from "@linkwarden/router/links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { restrictToWindowEdges, snapCenterToCursor } from "@dnd-kit/modifiers";
|
||||
import { snapCenterToCursor } from "@dnd-kit/modifiers";
|
||||
import { customCollisionDetectionAlgorithm } from "@/lib/utils";
|
||||
import { useUpdateTag } from "@linkwarden/router/tags";
|
||||
import usePinLink from "@/lib/client/pinLink";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
interface DragNDropProps {
|
||||
children: React.ReactNode;
|
||||
@@ -28,7 +30,6 @@ interface DragNDropProps {
|
||||
/**
|
||||
* All links available for drag and drop
|
||||
*/
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
setActiveLink: (link: LinkIncludingShortenedCollectionAndTags | null) => void;
|
||||
/**
|
||||
* Override the default sensors used for drag and drop.
|
||||
@@ -47,14 +48,15 @@ interface DragNDropProps {
|
||||
export default function DragNDrop({
|
||||
children,
|
||||
activeLink,
|
||||
links,
|
||||
setActiveLink,
|
||||
sensors: sensorProp,
|
||||
onDragEnd: onDragEndProp,
|
||||
}: DragNDropProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateTag = useUpdateTag();
|
||||
const updateLink = useUpdateLink();
|
||||
const pinLink = usePinLink();
|
||||
const { data: user } = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
const mouseSensor = useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
activationConstraint: {
|
||||
@@ -72,10 +74,10 @@ export default function DragNDrop({
|
||||
const sensors = useSensors(mouseSensor, touchSensor);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const draggedLink = links.find(
|
||||
(link: any) => link.id === event.active.data.current?.linkId
|
||||
setActiveLink(
|
||||
(event.active.data.current
|
||||
?.link as LinkIncludingShortenedCollectionAndTags) ?? null
|
||||
);
|
||||
setActiveLink(draggedLink || null);
|
||||
};
|
||||
|
||||
const handleDragOverCancel = () => {
|
||||
@@ -83,70 +85,169 @@ export default function DragNDrop({
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
// If an onDragEnd prop is provided, use it instead of the default behavior
|
||||
if (onDragEndProp) {
|
||||
onDragEndProp(event);
|
||||
return;
|
||||
}
|
||||
const { over } = event;
|
||||
|
||||
const { over, active } = event;
|
||||
if (!over || !activeLink) return;
|
||||
|
||||
let updatedLink: LinkIncludingShortenedCollectionAndTags | null = null;
|
||||
const overData = over.data.current;
|
||||
const targetId = String(over.id);
|
||||
|
||||
// if the link is dropped over a tag
|
||||
if (over.data.current?.type === "tag") {
|
||||
const isTagAlreadyExists = activeLink.tags.some(
|
||||
(tag) => tag.name === over.data.current?.name
|
||||
const isFromRecentSection = active.data.current?.dashboardType === "recent";
|
||||
|
||||
setActiveLink(null);
|
||||
|
||||
const mutateWithToast = async (
|
||||
updatedLink: LinkIncludingShortenedCollectionAndTags,
|
||||
opts?: { invalidateDashboardOnError?: boolean }
|
||||
) => {
|
||||
const load = toast.loading(t("updating"));
|
||||
await updateLink.mutateAsync(updatedLink, {
|
||||
onSettled: async (_, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) {
|
||||
if (
|
||||
opts?.invalidateDashboardOnError &&
|
||||
typeof queryClient !== "undefined"
|
||||
) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardData"],
|
||||
});
|
||||
}
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// DROP ON TAG
|
||||
if (overData?.type === "tag") {
|
||||
const tagName = overData?.name as string | undefined;
|
||||
if (!tagName) return;
|
||||
|
||||
const isTagAlreadyExists = activeLink.tags?.some(
|
||||
(tag) => tag.name === tagName
|
||||
);
|
||||
if (isTagAlreadyExists) {
|
||||
toast.error(t("tag_already_added"));
|
||||
return;
|
||||
}
|
||||
// to match the tags structure required to update the link
|
||||
const allTags: { name: string }[] = activeLink.tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
}));
|
||||
const newTags = [...allTags, { name: over.data.current?.name as string }];
|
||||
updatedLink = {
|
||||
|
||||
const allTags: { name: string }[] = (activeLink.tags ?? []).map(
|
||||
(tag) => ({
|
||||
name: tag.name,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
|
||||
...activeLink,
|
||||
tags: newTags as any,
|
||||
tags: [...allTags, { name: tagName }] as any,
|
||||
};
|
||||
} else {
|
||||
const collectionId = over.data.current?.id as number;
|
||||
const collectionName = over.data.current?.name as string;
|
||||
const ownerId = over.data.current?.ownerId as number;
|
||||
|
||||
// Immediately hide the drag overlay
|
||||
setActiveLink(null);
|
||||
|
||||
// if the link dropped over the same collection, toast
|
||||
if (activeLink.collection.id === collectionId) {
|
||||
toast.error(t("link_already_in_collection"));
|
||||
return;
|
||||
}
|
||||
|
||||
updatedLink = {
|
||||
...activeLink,
|
||||
collection: {
|
||||
id: collectionId,
|
||||
name: collectionName,
|
||||
ownerId,
|
||||
},
|
||||
};
|
||||
await mutateWithToast(updatedLink, {
|
||||
invalidateDashboardOnError: typeof queryClient !== "undefined",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
await updateLink.mutateAsync(updatedLink, {
|
||||
onSettled: (_, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
// DROP ON DASHBOARD "PINNED" SECTION
|
||||
const isPinnedSection = targetId === "pinned-links-section";
|
||||
|
||||
const canPin =
|
||||
typeof pinLink === "function" &&
|
||||
typeof user !== "undefined" &&
|
||||
typeof user?.id !== "undefined";
|
||||
|
||||
if (isPinnedSection && canPin) {
|
||||
if (Array.isArray(activeLink.pinnedBy) && !activeLink.pinnedBy.length) {
|
||||
if (typeof queryClient !== "undefined") {
|
||||
const optimisticallyPinned = {
|
||||
...activeLink,
|
||||
pinnedBy: [user!.id],
|
||||
};
|
||||
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.links) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
links: oldData.links.map((l: any) =>
|
||||
l.id === optimisticallyPinned.id ? optimisticallyPinned : l
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
pinLink(activeLink);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// DROP ON COLLECTION (dashboard + sidebar)
|
||||
const collectionId = overData?.id as number | undefined;
|
||||
const collectionName = overData?.name as string | undefined;
|
||||
const ownerId = overData?.ownerId as number | undefined;
|
||||
|
||||
if (!collectionId || !collectionName || typeof ownerId === "undefined")
|
||||
return;
|
||||
|
||||
const isSameCollection = activeLink.collection?.id === collectionId;
|
||||
if (isSameCollection) {
|
||||
if (isFromRecentSection) toast.error(t("link_already_in_collection"));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
|
||||
...activeLink,
|
||||
collection: {
|
||||
id: collectionId,
|
||||
name: collectionName,
|
||||
ownerId,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof queryClient !== "undefined") {
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.links) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
links: oldData.links.map((l: any) =>
|
||||
l.id === updatedLink.id ? updatedLink : l
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.collectionLinks) return oldData;
|
||||
|
||||
const oldCollectionId = activeLink.collection?.id;
|
||||
if (!oldCollectionId) return oldData;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
collectionLinks: {
|
||||
...oldData.collectionLinks,
|
||||
[oldCollectionId]: (
|
||||
oldData.collectionLinks[oldCollectionId] || []
|
||||
).filter((l: any) => l.id !== updatedLink.id),
|
||||
[collectionId]: [
|
||||
...(oldData.collectionLinks[collectionId] || []),
|
||||
updatedLink,
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await mutateWithToast(updatedLink, {
|
||||
invalidateDashboardOnError: typeof queryClient !== "undefined",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
onDragStart={handleDragStart}
|
||||
|
||||
@@ -187,6 +187,7 @@ export default function LinkDetails({
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40"></div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import ViewDropdown from "./ViewDropdown";
|
||||
import { TFunction } from "i18next";
|
||||
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
|
||||
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
|
||||
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||
import { useRouter } from "next/router";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
@@ -46,7 +45,8 @@ const LinkListOptions = ({
|
||||
setEditMode,
|
||||
links,
|
||||
}: Props) => {
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, setSelected, clearSelected, selectionCount } =
|
||||
useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
const refreshPreservations = useArchiveAction();
|
||||
@@ -62,45 +62,42 @@ const LinkListOptions = ({
|
||||
if (editMode && setEditMode) return setEditMode(false);
|
||||
}, [router]);
|
||||
|
||||
const collectivePermissions = useCollectivePermissions(
|
||||
selectedLinks.map((link) => link.collectionId as number)
|
||||
);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedLinks.length === links.length) {
|
||||
setSelectedLinks([]);
|
||||
if (selectionCount === links.length) {
|
||||
clearSelected();
|
||||
} else {
|
||||
setSelectedLinks(links.map((link) => link));
|
||||
setSelected(links.map((link) => link.id as number));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteLinks = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
|
||||
await deleteLinksById.mutateAsync(
|
||||
selectedLinks.map((link) => link.id as number),
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
setEditMode?.(false);
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
await deleteLinksById.mutateAsync(ids, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
clearSelected();
|
||||
setEditMode?.(false);
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const bulkRefreshPreservations = async () => {
|
||||
const load = toast.loading(t("sending_request"));
|
||||
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
await refreshPreservations.mutateAsync(
|
||||
{
|
||||
linkIds: selectedLinks.map((link) => link.id as number),
|
||||
linkIds: ids,
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
@@ -108,7 +105,7 @@ const LinkListOptions = ({
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
setEditMode?.(false);
|
||||
toast.success(t("links_being_archived"));
|
||||
}
|
||||
@@ -133,7 +130,7 @@ const LinkListOptions = ({
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
}}
|
||||
className={
|
||||
editMode ? "bg-primary/20 hover:bg-primary/20" : ""
|
||||
@@ -161,15 +158,15 @@ const LinkListOptions = ({
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
checked={selectionCount === links.length && links.length > 0}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
{selectionCount > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("link_selected")
|
||||
: t("links_selected", { count: selectedLinks.length })}
|
||||
: t("links_selected", {
|
||||
count: selectionCount,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("nothing_selected")}</span>
|
||||
@@ -183,7 +180,7 @@ const LinkListOptions = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setBulkRefreshPreservationsModal(true)}
|
||||
disabled={selectedLinks.length === 0}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-arrow-clockwise" />
|
||||
</Button>
|
||||
@@ -201,13 +198,7 @@ const LinkListOptions = ({
|
||||
onClick={() => setBulkEditLinksModal(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canUpdate
|
||||
)
|
||||
}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-pencil-square" />
|
||||
</Button>
|
||||
@@ -229,13 +220,7 @@ const LinkListOptions = ({
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canDelete
|
||||
)
|
||||
}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-trash text-error" />
|
||||
</Button>
|
||||
@@ -278,10 +263,10 @@ const LinkListOptions = ({
|
||||
title={t("refresh_preserved_formats")}
|
||||
>
|
||||
<p className="mb-5">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("refresh_preserved_formats_confirmation_desc")
|
||||
: t("refresh_multiple_preserved_formats_confirmation_desc", {
|
||||
count: selectedLinks.length,
|
||||
count: selectionCount,
|
||||
})}
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useDeleteLink, useGetLink } from "@linkwarden/router/links";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkModal from "@/components/ModalContent/LinkModal";
|
||||
@@ -21,25 +17,25 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConfirmationModal from "@/components/ConfirmationModal";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
linkModal: boolean;
|
||||
className?: string;
|
||||
setLinkModal: (value: boolean) => void;
|
||||
t: TFunction<"translation", undefined>;
|
||||
className?: string;
|
||||
ghost?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkActions({
|
||||
link,
|
||||
linkModal,
|
||||
className,
|
||||
t,
|
||||
setLinkModal,
|
||||
className,
|
||||
ghost,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const permissions = usePermissions(link.collection.id as number);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
@@ -15,15 +14,8 @@ import {
|
||||
formatAvailable,
|
||||
} from "@linkwarden/lib/formatStats";
|
||||
import LinkIcon from "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
||||
import { useRouter } from "next/router";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import LinkPin from "./LinkPin";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
@@ -31,146 +23,68 @@ import openLink from "@/lib/client/openLink";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
columns: number;
|
||||
className?: string;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
user: any;
|
||||
disableDraggable: boolean;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
imageHeightClass: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
function LinkCard({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
user,
|
||||
disableDraggable,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
imageHeightClass,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
},
|
||||
disabled: isSmallScreen,
|
||||
disabled: disableDraggable,
|
||||
});
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
const router = useRouter();
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (
|
||||
isVisible &&
|
||||
!link.preview?.startsWith("archives") &&
|
||||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
refetch().catch((error) => {
|
||||
console.error("Error refetching link:", error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const isLinkSelected = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
||||
isLinkSelected && "border-primary bg-base-300",
|
||||
isSelected && "border-primary bg-base-300",
|
||||
isDragging ? "opacity-30" : "opacity-100",
|
||||
"relative group touch-manipulation select-none"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
editMode
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div ref={ref}>
|
||||
<div ref={ref} className="h-full">
|
||||
<div
|
||||
className="rounded-xl cursor-pointer h-full flex flex-col justify-between"
|
||||
onClick={() =>
|
||||
@@ -197,6 +111,7 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div
|
||||
@@ -240,9 +155,13 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
<Separator className="mb-1" />
|
||||
|
||||
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
|
||||
{show.collection && !isPublicRoute && (
|
||||
{show.collection && !isPublicRoute && collection && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={isPublicRoute}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
@@ -256,8 +175,8 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
/>
|
||||
@@ -266,3 +185,5 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkCard);
|
||||
|
||||
@@ -5,20 +5,17 @@ import {
|
||||
} from "@linkwarden/types";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
export default function LinkCollection({
|
||||
function LinkCollection({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
return !isPublicRoute && collection?.name ? (
|
||||
<>
|
||||
<Link
|
||||
@@ -47,3 +44,5 @@ export default function LinkCollection({
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default React.memo(LinkCollection);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import React from "react";
|
||||
|
||||
export default function LinkDate({
|
||||
link,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}) {
|
||||
function LinkDate({ link }: { link: LinkIncludingShortenedCollectionAndTags }) {
|
||||
const formattedDate = new Date(
|
||||
(link.importDate || link.createdAt) as string
|
||||
).toLocaleString("en-US", {
|
||||
@@ -21,3 +17,5 @@ export default function LinkDate({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkDate);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IconWeight } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
|
||||
|
||||
export default function LinkIcon({
|
||||
function LinkIcon({
|
||||
link,
|
||||
className,
|
||||
hideBackground,
|
||||
@@ -45,17 +45,17 @@ export default function LinkIcon({
|
||||
) : link.type === "url" && url ? (
|
||||
<>
|
||||
<Image
|
||||
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=64`}
|
||||
src={`/api/v1/getFavicon?url=${encodeURIComponent(url.origin)}`}
|
||||
width={64}
|
||||
height={64}
|
||||
alt=""
|
||||
unoptimized
|
||||
className={clsx(
|
||||
iconClasses,
|
||||
faviconLoaded ? "" : "absolute opacity-0"
|
||||
)}
|
||||
draggable="false"
|
||||
onLoadingComplete={() => setFaviconLoaded(true)}
|
||||
onError={() => setFaviconLoaded(false)}
|
||||
onLoad={() => setFaviconLoaded(true)}
|
||||
/>
|
||||
{!faviconLoaded && (
|
||||
<LinkPlaceholderIcon
|
||||
@@ -104,3 +104,5 @@ const LinkPlaceholderIcon = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(LinkIcon);
|
||||
|
||||
@@ -2,113 +2,61 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
||||
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
||||
import { cn, isPWA } from "@/lib/utils";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
import { atLeastOneFormatAvailable } from "@linkwarden/lib/formatStats";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
import openLink from "@/lib/client/openLink";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
count: number;
|
||||
className?: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
function LinkList({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
},
|
||||
disabled: isSmallScreen,
|
||||
disabled: disableDraggable,
|
||||
});
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user } = useUser();
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
const linkIndex = selectedLinks.findIndex(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
if (linkIndex !== -1) {
|
||||
const updatedLinks = [...selectedLinks];
|
||||
updatedLinks.splice(linkIndex, 1);
|
||||
setSelectedLinks(updatedLinks);
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border border-primary bg-base-300"
|
||||
: "border-transparent";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -117,14 +65,16 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"rounded-md border relative group items-center flex",
|
||||
selectedStyle,
|
||||
isSelected
|
||||
? "border border-primary bg-base-300"
|
||||
: "border-transparent",
|
||||
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1",
|
||||
isDragging ? "opacity-30" : "opacity-100",
|
||||
"duration-200, touch-manipulation select-none"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
editMode
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
@@ -163,19 +113,23 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
||||
<div className="flex items-center gap-x-3 text-neutral flex-wrap">
|
||||
{show.link && <LinkTypeBadge link={link} />}
|
||||
{show.collection && (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
{show.collection && collection && (
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={isPublicRoute}
|
||||
/>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isPublic && <LinkPin link={link} />}
|
||||
{!isPublicRoute && <LinkPin link={link} />}
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
/>
|
||||
@@ -184,3 +138,5 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkList);
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
@@ -16,142 +15,58 @@ import {
|
||||
} from "@linkwarden/lib/formatStats";
|
||||
import Link from "next/link";
|
||||
import LinkIcon from "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import clsx from "clsx";
|
||||
import LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
import openLink from "@/lib/client/openLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
columns: number;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
imageHeightClass: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
function LinkMasonry({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
imageHeightClass,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
},
|
||||
disabled: isSmallScreen,
|
||||
disabled: disableDraggable,
|
||||
});
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (
|
||||
isVisible &&
|
||||
!link.preview?.startsWith("archives") &&
|
||||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
refetch().catch((error) => {
|
||||
console.error("Error refetching link:", error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const isLinkSelected = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
|
||||
@@ -160,11 +75,11 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
||||
isLinkSelected && "border-primary bg-base-300"
|
||||
isSelected && "border-primary bg-base-300"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
editMode
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
@@ -195,6 +110,7 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? null : (
|
||||
<div
|
||||
@@ -258,9 +174,13 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
<Separator className="mb-1" />
|
||||
|
||||
<div className="flex flex-wrap justify-between items-center text-xs text-neutral px-3 pb-1 w-full gap-x-2">
|
||||
{!isPublicRoute && show.collection && (
|
||||
{!isPublicRoute && show.collection && collection && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={isPublicRoute}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
@@ -273,8 +193,8 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
/>
|
||||
@@ -283,3 +203,5 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkMasonry);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function LinkTypeBadge({
|
||||
function LinkTypeBadge({
|
||||
link,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
@@ -50,3 +50,5 @@ export default function LinkTypeBadge({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkTypeBadge);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
@@ -7,26 +8,43 @@ import { useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
||||
import Masonry from "react-masonry-css";
|
||||
import resolveConfig from "tailwindcss/resolveConfig";
|
||||
import tailwindConfig from "../../tailwind.config.js";
|
||||
import { useMemo } from "react";
|
||||
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import useLinkStore from "@/store/links";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
export function CardView({
|
||||
function CardView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
user,
|
||||
disableDraggable,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
user: any;
|
||||
disableDraggable: boolean;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
const settings = useLocalSettingsStore((state) => state.settings);
|
||||
|
||||
@@ -59,6 +77,23 @@ export function CardView({
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() =>
|
||||
columnCount ? heightMap[columnCount as keyof typeof heightMap] : "h-40",
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (settings.columns === 0) {
|
||||
@@ -82,51 +117,66 @@ export function CardView({
|
||||
|
||||
return (
|
||||
<div className={`${gridColClass} grid gap-5 pb-5`}>
|
||||
{links?.map((e, i) => {
|
||||
{links?.map((e) => {
|
||||
const collection = collectionsById.get(e.collection.id as number);
|
||||
const selected = isSelected(e.id as number);
|
||||
|
||||
return (
|
||||
<LinkCard
|
||||
key={i}
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
user={user}
|
||||
disableDraggable={disableDraggable}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
editMode={editMode}
|
||||
columns={columnCount}
|
||||
imageHeightClass={imageHeightClass}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(hasNextPage || isLoading) && (
|
||||
<div className="flex flex-col gap-4" ref={placeHolderRef}>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MasonryView({
|
||||
function MasonryView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
const settings = useLocalSettingsStore((state) => state.settings);
|
||||
|
||||
@@ -159,6 +209,23 @@ export function MasonryView({
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() =>
|
||||
columnCount ? heightMap[columnCount as keyof typeof heightMap] : "h-40",
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (settings.columns === 0) {
|
||||
@@ -180,17 +247,7 @@ export function MasonryView({
|
||||
};
|
||||
}, [settings.columns]);
|
||||
|
||||
const fullConfig = resolveConfig(tailwindConfig as any);
|
||||
|
||||
const breakpointColumnsObj = useMemo(() => {
|
||||
return {
|
||||
default: 5,
|
||||
1900: 4,
|
||||
1500: 3,
|
||||
880: 2,
|
||||
550: 1,
|
||||
};
|
||||
}, []);
|
||||
const breakpointColumnsObj = { default: 5, 1900: 4, 1500: 3, 880: 2, 550: 1 };
|
||||
|
||||
return (
|
||||
<Masonry
|
||||
@@ -200,75 +257,100 @@ export function MasonryView({
|
||||
columnClassName="flex flex-col gap-5 !w-full"
|
||||
className={`${gridColClass} grid gap-5 pb-5`}
|
||||
>
|
||||
{links?.map((e, i) => {
|
||||
{links?.map((e) => {
|
||||
const collection = collectionsById.get(e.collection.id as number);
|
||||
const selected = isSelected(e.id as number);
|
||||
|
||||
return (
|
||||
<LinkMasonry
|
||||
key={i}
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
imageHeightClass={imageHeightClass}
|
||||
editMode={editMode}
|
||||
columns={columnCount}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(hasNextPage || isLoading) && (
|
||||
<div className="flex flex-col gap-4" ref={placeHolderRef}>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
)}
|
||||
</Masonry>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListView({
|
||||
function ListView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{links?.map((e, i) => {
|
||||
return <LinkList key={i} link={e} count={i} editMode={editMode} />;
|
||||
const collection = collectionsById.get(e.collection.id as number);
|
||||
const selected = isSelected(e.id as number);
|
||||
|
||||
return (
|
||||
<LinkList
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
count={i}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
className="flex gap-2 py-2 px-1"
|
||||
>
|
||||
<div className="skeleton h-12 w-12"></div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="skeleton h-2 w-2/3"></div>
|
||||
<div className="skeleton h-2 w-full"></div>
|
||||
<div className="skeleton h-2 w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(hasNextPage || isLoading) && (
|
||||
<div ref={placeHolderRef} className="flex gap-2 py-2 px-1">
|
||||
<div className="skeleton h-12 w-12"></div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="skeleton h-2 w-2/3"></div>
|
||||
<div className="skeleton h-2 w-full"></div>
|
||||
<div className="skeleton h-2 w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -277,30 +359,88 @@ export default function Links({
|
||||
layout,
|
||||
links,
|
||||
editMode,
|
||||
placeholderCount,
|
||||
useData,
|
||||
}: {
|
||||
layout: ViewMode;
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
placeholderCount?: number;
|
||||
useData?: any;
|
||||
}) {
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
|
||||
useData.fetchNextPage();
|
||||
if (!inView) return;
|
||||
if (!useData.hasNextPage) return;
|
||||
if (useData.isFetchingNextPage) return;
|
||||
|
||||
useData.fetchNextPage();
|
||||
}, [
|
||||
inView,
|
||||
useData.hasNextPage,
|
||||
useData.isFetchingNextPage,
|
||||
useData.fetchNextPage,
|
||||
]);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const collectionsById = useMemo(() => {
|
||||
const m = new Map<number, (typeof collections)[number]>();
|
||||
for (const c of collections) m.set(c.id as any, c);
|
||||
return m;
|
||||
}, [collections]);
|
||||
|
||||
const { clearSelected, isSelected, toggleSelected } = useLinkStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
clearSelected();
|
||||
}
|
||||
}, [useData, inView]);
|
||||
}, [editMode]);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (
|
||||
links?.some(
|
||||
(e) => !e.preview?.startsWith("archives") && e.preview !== "unavailable"
|
||||
)
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
useData.refetch().catch((error: any) => {
|
||||
console.error("Error refetching link:", error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [links]);
|
||||
|
||||
const disableDraggable = useMediaQuery("(max-width: 1023px)");
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (layout === ViewMode.List) {
|
||||
return (
|
||||
<ListView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
@@ -308,10 +448,16 @@ export default function Links({
|
||||
} else if (layout === ViewMode.Masonry) {
|
||||
return (
|
||||
<MasonryView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
@@ -320,16 +466,19 @@ export default function Links({
|
||||
// Default to card view
|
||||
return (
|
||||
<CardView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
user={user}
|
||||
disableDraggable={disableDraggable}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderCountToArray = (num?: number) =>
|
||||
num ? Array.from({ length: num }, (_, i) => i + 1) : [];
|
||||
|
||||
@@ -13,47 +13,45 @@ type Props = {
|
||||
|
||||
export default function BulkDeleteLinksModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
|
||||
const deleteLink = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
await deleteLinksById.mutateAsync(
|
||||
selectedLinks.map((link) => link.id as number),
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
await deleteLinksById.mutateAsync(ids, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
onClose();
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
clearSelected();
|
||||
onClose();
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin text-red-500">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("delete_link")
|
||||
: t("delete_links", { count: selectedLinks.length })}
|
||||
: t("delete_links", { count: selectionCount })}
|
||||
</p>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("link_deletion_confirmation_message")
|
||||
: t("links_deletion_confirmation_message", {
|
||||
count: selectedLinks.length,
|
||||
count: selectionCount,
|
||||
})}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
|
||||
export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [removePreviousTags, setRemovePreviousTags] = useState(false);
|
||||
const [updatedValues, setUpdatedValues] = useState<
|
||||
@@ -40,9 +40,13 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
|
||||
const links = Object.keys(selectedIds).map((k) => ({
|
||||
id: Number(k),
|
||||
}));
|
||||
|
||||
await updateLinks.mutateAsync(
|
||||
{
|
||||
links: selectedLinks,
|
||||
links,
|
||||
newData: updatedValues,
|
||||
removePreviousTags,
|
||||
},
|
||||
@@ -54,7 +58,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
@@ -67,9 +71,9 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("edit_link")
|
||||
: t("edit_links", { count: selectedLinks.length })}
|
||||
: t("edit_links", { count: selectionCount })}
|
||||
</p>
|
||||
<Separator className="my-3" />
|
||||
|
||||
|
||||
@@ -66,6 +66,16 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
|
||||
}
|
||||
}, [currentFormat]);
|
||||
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
if (img.complete && img.naturalWidth > 0) {
|
||||
setImageLoaded(true);
|
||||
}
|
||||
}, [currentFormat, link?.id, link?.updatedAt]);
|
||||
|
||||
if (!link?.id) return null;
|
||||
|
||||
const renderFormat = () => {
|
||||
@@ -126,6 +136,7 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
ref={imgRef}
|
||||
src={`/api/v1/archives/${link.id}?format=${currentFormat}`}
|
||||
className={clsx("w-fit mx-auto", !imageLoaded && "hidden")}
|
||||
onLoad={(e) => {
|
||||
|
||||
@@ -28,11 +28,10 @@ import HighlightDrawer from "../HighlightDrawer";
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
format?: ArchivedFormat;
|
||||
showNavbar: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
||||
const PreservationNavbar = ({ link, format, className }: Props) => {
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const [collection, setCollection] =
|
||||
@@ -83,8 +82,7 @@ const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"p-2 z-10 bg-base-100 flex gap-2 justify-between transform transition-transform duration-200 ease-in-out fixed top-0 left-0 right-0",
|
||||
showNavbar ? "translate-y-0" : "-translate-y-full",
|
||||
"p-2 z-10 bg-base-100 flex gap-2 justify-between fixed top-0 left-0 right-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -217,7 +215,7 @@ const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
||||
<ToggleDarkMode />
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
t={t}
|
||||
linkModal={linkModal}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
ghost
|
||||
|
||||
@@ -3,15 +3,12 @@ import { useRouter } from "next/router";
|
||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
||||
import { PreservationContent } from "./PreservationContent";
|
||||
import PreservationNavbar from "./PreservationNavbar";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
|
||||
export default function PreservationPageContent() {
|
||||
const router = useRouter();
|
||||
const { links } = useLinks();
|
||||
|
||||
const [showNavbar, setShowNavbar] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
@@ -41,41 +38,13 @@ export default function PreservationPageContent() {
|
||||
};
|
||||
}, [links]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
const st = container.scrollTop;
|
||||
// if scrolling down and beyond a small threshold, hide
|
||||
if (st - 10 > lastScrollTop.current) {
|
||||
if (Number(router.query.format) === ArchivedFormat.readability)
|
||||
setShowNavbar(false);
|
||||
}
|
||||
// if scrolling up, show
|
||||
else if (st < lastScrollTop.current - 10) {
|
||||
setShowNavbar(true);
|
||||
}
|
||||
lastScrollTop.current = st <= 0 ? 0 : st; // for Mobile or negative
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
}, [router.query.format]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{link?.id && (
|
||||
<PreservationNavbar
|
||||
link={link}
|
||||
format={Number(router.query.format)}
|
||||
showNavbar={showNavbar}
|
||||
/>
|
||||
<PreservationNavbar link={link} format={Number(router.query.format)} />
|
||||
)}
|
||||
<div
|
||||
className={`bg-base-200 overflow-y-auto w-screen ${
|
||||
showNavbar ? "h-[calc(100vh-3.1rem)] mt-[3.1rem]" : "h-screen"
|
||||
}`}
|
||||
className={`bg-base-200 overflow-y-auto w-screen h-[calc(100vh-3.1rem)] mt-[3.1rem]`}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<PreservationContent link={link} format={Number(router.query.format)} />
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function ProfilePhoto({
|
||||
draggable={false}
|
||||
onError={() => setImage("")}
|
||||
className="aspect-square rounded-full"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import CollectionListing from "@/components/CollectionListing";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
import { TagListing } from "./TagListing";
|
||||
import TagListing from "./TagListing";
|
||||
import { Button } from "./ui/button";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import Image from "next/image";
|
||||
@@ -90,6 +90,7 @@ export default function Sidebar({
|
||||
alt="Linkwarden Icon"
|
||||
className="h-8 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
) : user?.theme === "light" ? (
|
||||
<Image
|
||||
@@ -99,6 +100,7 @@ export default function Sidebar({
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
@@ -108,6 +110,7 @@ export default function Sidebar({
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@ interface TagListingProps {
|
||||
tags: Tag[];
|
||||
active?: string;
|
||||
}
|
||||
export function TagListing({ tags, active }: TagListingProps) {
|
||||
|
||||
export default function TagListing({ tags, active }: TagListingProps) {
|
||||
const { active: droppableActive } = useDndContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ctx = useDndContext();
|
||||
console.log("DndContext active?", ctx.active);
|
||||
|
||||
if (!tags[0]) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -3,6 +3,10 @@ import Announcement from "@/components/Announcement";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import getLatestVersion from "@/lib/client/getLatestVersion";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -40,29 +44,34 @@ export default function MainLayout({ children }: Props) {
|
||||
const toggleAnnouncementBar = () => setShowAnnouncement(!showAnnouncement);
|
||||
const toggleSidebar = () => setSidebarIsCollapsed(!sidebarIsCollapsed);
|
||||
|
||||
return (
|
||||
<div className="flex" data-testid="dashboard-wrapper">
|
||||
{showAnnouncement && (
|
||||
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
|
||||
)}
|
||||
<div className="hidden lg:block">
|
||||
<Sidebar
|
||||
className={`${sidebarIsCollapsed ? "w-14" : "w-80"}`}
|
||||
toggleSidebar={toggleSidebar}
|
||||
sidebarIsCollapsed={sidebarIsCollapsed}
|
||||
/>
|
||||
</div>
|
||||
const [activeLink, setActiveLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags | null>(null);
|
||||
|
||||
<div
|
||||
className={`${
|
||||
sidebarIsCollapsed
|
||||
? "lg:w-[calc(100%-56px)]"
|
||||
: "lg:w-[calc(100%-320px)]"
|
||||
} w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto`}
|
||||
>
|
||||
<Navbar />
|
||||
{children}
|
||||
return (
|
||||
<DragNDrop activeLink={activeLink} setActiveLink={setActiveLink}>
|
||||
<div className="flex" data-testid="dashboard-wrapper">
|
||||
{showAnnouncement && (
|
||||
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
|
||||
)}
|
||||
<div className="hidden lg:block">
|
||||
<Sidebar
|
||||
className={`${sidebarIsCollapsed ? "w-14" : "w-80"}`}
|
||||
toggleSidebar={toggleSidebar}
|
||||
sidebarIsCollapsed={sidebarIsCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${
|
||||
sidebarIsCollapsed
|
||||
? "lg:w-[calc(100%-56px)]"
|
||||
: "lg:w-[calc(100%-320px)]"
|
||||
} w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto`}
|
||||
>
|
||||
<Navbar />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DragNDrop>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import updateLinkById from "../linkId/updateLinkById";
|
||||
import { UpdateLinkSchemaType } from "@linkwarden/lib/schemaValidation";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
|
||||
export default async function updateLinks(
|
||||
userId: number,
|
||||
links: UpdateLinkSchemaType[],
|
||||
links: { id: number }[],
|
||||
removePreviousTags: boolean,
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
@@ -13,19 +14,35 @@ export default async function updateLinks(
|
||||
) {
|
||||
let allUpdatesSuccessful = true;
|
||||
|
||||
// Have to use a loop here rather than updateMany, see the following:
|
||||
// https://github.com/prisma/prisma/issues/3143
|
||||
for (const link of links) {
|
||||
let updatedTags = [...link.tags, ...(newData.tags ?? [])];
|
||||
const ids = links.map((l) => l.id);
|
||||
|
||||
if (removePreviousTags) {
|
||||
// If removePreviousTags is true, replace the existing tags with new tags
|
||||
updatedTags = [...(newData.tags ?? [])];
|
||||
}
|
||||
const dbLinks = await prisma.link.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
description: true,
|
||||
icon: true,
|
||||
iconWeight: true,
|
||||
color: true,
|
||||
collectionId: true,
|
||||
collection: { select: { id: true, ownerId: true } },
|
||||
tags: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Map id -> link for quick lookup
|
||||
const byId = new Map(dbLinks.map((l) => [l.id, l]));
|
||||
|
||||
for (const l of links) {
|
||||
const link = byId.get(l.id);
|
||||
|
||||
if (!link) continue;
|
||||
|
||||
const updatedData: UpdateLinkSchemaType = {
|
||||
...link,
|
||||
tags: updatedTags,
|
||||
tags: [...(newData.tags ?? [])],
|
||||
collection: {
|
||||
...link.collection,
|
||||
id: newData.collectionId ?? link.collection.id,
|
||||
@@ -35,7 +52,8 @@ export default async function updateLinks(
|
||||
const updatedLink = await updateLinkById(
|
||||
userId,
|
||||
link.id as number,
|
||||
updatedData
|
||||
updatedData,
|
||||
removePreviousTags
|
||||
);
|
||||
|
||||
if (updatedLink.status !== 200) {
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
export default async function updateLinkById(
|
||||
userId: number,
|
||||
linkId: number,
|
||||
body: UpdateLinkSchemaType
|
||||
body: UpdateLinkSchemaType,
|
||||
removePreviousTags?: boolean
|
||||
) {
|
||||
const dataValidation = UpdateLinkSchema.safeParse(body);
|
||||
|
||||
@@ -105,6 +106,30 @@ export default async function updateLinkById(
|
||||
},
|
||||
});
|
||||
|
||||
const uniqueTags = (() => {
|
||||
const seen = new Set<string>();
|
||||
return (data.tags ?? []).filter((t) => {
|
||||
const key = t.name;
|
||||
if (!key) return false;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
})();
|
||||
|
||||
const tagConnectOrCreate = uniqueTags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name,
|
||||
ownerId: data.collection.ownerId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag.name,
|
||||
owner: { connect: { id: data.collection.ownerId } },
|
||||
},
|
||||
}));
|
||||
|
||||
if (
|
||||
data.url &&
|
||||
oldLink &&
|
||||
@@ -140,25 +165,14 @@ export default async function updateLinkById(
|
||||
id: data.collection.id,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
set: [],
|
||||
connectOrCreate: data.tags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name,
|
||||
ownerId: data.collection.ownerId,
|
||||
},
|
||||
tags: removePreviousTags
|
||||
? {
|
||||
set: [],
|
||||
connectOrCreate: tagConnectOrCreate,
|
||||
}
|
||||
: {
|
||||
connectOrCreate: tagConnectOrCreate,
|
||||
},
|
||||
create: {
|
||||
name: tag.name,
|
||||
owner: {
|
||||
connect: {
|
||||
id: data.collection.ownerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
pinnedBy: data?.pinnedBy
|
||||
? data.pinnedBy[0]?.id === userId
|
||||
? { connect: { id: userId } }
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
export default async function getLatestVersion(setShowAnnouncement: Function) {
|
||||
const announcementId = localStorage.getItem("announcementId");
|
||||
const announcementMessage = localStorage.getItem("announcementMessage");
|
||||
|
||||
const response = await fetch(
|
||||
`https://blog.linkwarden.app/latest-announcement.json`
|
||||
`https://linkwarden.app/blog/latest-announcement.json`
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const latestAnnouncement = data.id;
|
||||
const latestMessage = data.message;
|
||||
|
||||
if (announcementId !== latestAnnouncement) {
|
||||
if (
|
||||
announcementId !== latestAnnouncement ||
|
||||
announcementMessage !== latestMessage
|
||||
) {
|
||||
setShowAnnouncement(true);
|
||||
localStorage.setItem("announcementId", latestAnnouncement);
|
||||
if (latestAnnouncement)
|
||||
localStorage.setItem("announcementId", latestAnnouncement);
|
||||
if (latestMessage)
|
||||
localStorage.setItem("announcementMessage", latestMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,9 @@ const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
staticPageGenerationTimeout: 1000,
|
||||
images: {
|
||||
// For fetching the favicons
|
||||
domains: ["t2.gstatic.com"],
|
||||
|
||||
// For profile pictures (Google OAuth)
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
// For profile pictures (Google OAuth)
|
||||
{ hostname: "*.googleusercontent.com" },
|
||||
],
|
||||
|
||||
minimumCacheTTL: 10,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@linkwarden/web",
|
||||
"version": "v2.13.2",
|
||||
"version": "v2.13.3",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||
@@ -39,13 +39,6 @@
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tanstack/react-query": "^5.51.15",
|
||||
"@tanstack/react-query-devtools": "^5.51.15",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/node": "^20.10.4",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/react": "18.3.20",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/rss": "^0.0.32",
|
||||
"axios": "^1.5.1",
|
||||
"bcrypt": "^5.1.0",
|
||||
"bootstrap-icons": "^1.11.2",
|
||||
@@ -53,8 +46,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"colorjs.io": "^0.5.2",
|
||||
"csstype": "^3.1.2",
|
||||
"dompurify": "^3.0.6",
|
||||
"eslint": "8.46.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"eslint-config-next": "13.4.9",
|
||||
"formidable": "^3.5.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
@@ -67,15 +59,16 @@
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.12",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-i18next": "^15.3.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"papaparse": "^5.5.3",
|
||||
"playwright": "^1.55.0",
|
||||
"playwright": "1.57.0",
|
||||
"react": "18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-image-file-resizer": "^0.4.8",
|
||||
@@ -86,25 +79,33 @@
|
||||
"react-window": "^1.8.10",
|
||||
"rss": "^1.2.2",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sharp": "^0.34.5",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"stripe": "^18.4.0",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/dompurify": "^3.0.4",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/jsdom": "^21.1.3",
|
||||
"@types/node": "^20.10.4",
|
||||
"@types/node-fetch": "^2.6.10",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/rss": "^0.0.32",
|
||||
"@types/shelljs": "^0.8.15",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"daisyui": "^4.4.2",
|
||||
"eslint": "8.46.0",
|
||||
"postcss": "^8.4.26",
|
||||
"prettier": "3.1.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { ReactElement, ReactNode, useEffect } from "react";
|
||||
import "@/styles/globals.css";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
@@ -13,6 +13,7 @@ import { isPWA } from "@/lib/utils";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NextPage } from "next";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -22,12 +23,19 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
function App({
|
||||
Component,
|
||||
pageProps,
|
||||
}: AppProps<{
|
||||
session: Session;
|
||||
}>) {
|
||||
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
|
||||
getLayout?: (page: ReactElement) => ReactNode;
|
||||
};
|
||||
|
||||
type PageProps = { session?: Session | null };
|
||||
|
||||
type AppPropsWithLayout = AppProps<PageProps> & {
|
||||
Component: NextPageWithLayout<PageProps>;
|
||||
};
|
||||
|
||||
function App({ Component, pageProps }: AppPropsWithLayout) {
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPWA()) {
|
||||
const meta = document.createElement("meta");
|
||||
@@ -98,7 +106,7 @@ function App({
|
||||
</ToastBar>
|
||||
)}
|
||||
</Toaster>
|
||||
<Component {...pageProps} />
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
{/* </GetData> */}
|
||||
</AuthRedirect>
|
||||
</SessionProvider>
|
||||
|
||||
@@ -130,7 +130,10 @@ async function handleGet(req: NextApiRequest, res: NextApiResponse) {
|
||||
: `archives/${collection.id}/${linkId + suffix}`;
|
||||
|
||||
const { file, contentType, status } = await readFile(filePath);
|
||||
res.setHeader("Content-Type", contentType).status(status as number);
|
||||
res
|
||||
.setHeader("Content-Type", contentType)
|
||||
.setHeader("Cache-Control", "private, max-age=31536000, immutable")
|
||||
.status(status as number);
|
||||
return res.send(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ if (emailEnabled) {
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
async sendVerificationRequest({ identifier, url, provider, token }) {
|
||||
async sendVerificationRequest({ identifier, url, provider, token }: any) {
|
||||
const recentVerificationRequestsCount =
|
||||
await prisma.verificationToken.count({
|
||||
where: {
|
||||
@@ -160,13 +160,13 @@ if (emailEnabled) {
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
} as any),
|
||||
EmailProvider({
|
||||
id: "invite",
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
async sendVerificationRequest({ identifier, url, provider, token }) {
|
||||
async sendVerificationRequest({ identifier, url, provider, token }: any) {
|
||||
const parentSubscriptionEmail = (
|
||||
await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -210,7 +210,7 @@ if (emailEnabled) {
|
||||
token,
|
||||
});
|
||||
},
|
||||
})
|
||||
} as any)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
return res
|
||||
.setHeader("Content-Type", contentType)
|
||||
.setHeader("Cache-Control", "private, max-age=31536000, immutable")
|
||||
.status(status as number)
|
||||
.send(file);
|
||||
}
|
||||
|
||||
91
apps/web/pages/api/v1/getFavicon/index.ts
Normal file
91
apps/web/pages/api/v1/getFavicon/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
function isImage(ct: string | null) {
|
||||
return !!ct && ct.toLowerCase().startsWith("image/");
|
||||
}
|
||||
|
||||
async function fetchImage(src: string, timeoutMs = 1500) {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const r = await fetch(src, {
|
||||
method: "GET",
|
||||
headers: { Accept: "image/*" },
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!r.ok || !r.body) return null;
|
||||
|
||||
const ct = r.headers.get("content-type");
|
||||
if (!isImage(ct)) return null;
|
||||
|
||||
return { body: r.body, contentType: ct! };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== "GET") return res.status(405).end();
|
||||
|
||||
const raw = req.query.url;
|
||||
const urlStr = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!urlStr) return res.status(400).end();
|
||||
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(decodeURIComponent(urlStr));
|
||||
} catch {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
const origin = u.origin;
|
||||
const hostname = u.hostname;
|
||||
|
||||
const canonical = `/api/v1/getFavicon?url=${encodeURIComponent(origin)}`;
|
||||
if (req.url !== canonical) {
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
return res.redirect(308, canonical);
|
||||
}
|
||||
|
||||
const sources = [
|
||||
`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(
|
||||
origin
|
||||
)}&size=64`,
|
||||
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
|
||||
];
|
||||
|
||||
for (const src of sources) {
|
||||
const hit = await fetchImage(src);
|
||||
if (!hit) continue;
|
||||
|
||||
res.status(200);
|
||||
res.setHeader("Content-Type", hit.contentType);
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"public, max-age=86400, s-maxage=2592000, stale-while-revalidate=604800, immutable"
|
||||
);
|
||||
|
||||
Readable.fromWeb(hit.body as any).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204);
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800"
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||
const updated = await updateLinkById(
|
||||
user.id,
|
||||
Number(req.query.id),
|
||||
req.body
|
||||
req.body,
|
||||
true // since we're passing the existing tags into the request body
|
||||
);
|
||||
return res.status(updated.status).json({
|
||||
response: updated.response,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import ProfilePhoto from "@/components/ProfilePhoto";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
@@ -36,9 +36,9 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Index() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -112,300 +112,291 @@ export default function Index() {
|
||||
);
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
<div
|
||||
className="p-5 flex gap-3 flex-col"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${activeCollection?.color}20 0%, ${
|
||||
user?.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${user?.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
<MainLayout>
|
||||
<div
|
||||
className="p-5 flex gap-3 flex-col"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${activeCollection?.color}20 0%, ${
|
||||
user?.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${user?.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
{activeCollection && (
|
||||
<div className="flex gap-3 items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCollection.icon ? (
|
||||
<Icon
|
||||
icon={activeCollection.icon}
|
||||
size={45}
|
||||
weight={
|
||||
(activeCollection.iconWeight || "regular") as IconWeight
|
||||
}
|
||||
color={activeCollection.color}
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className="bi-folder-fill text-3xl"
|
||||
style={{ color: activeCollection.color }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="sm:text-3xl text-2xl w-full py-1 break-words hyphens-auto font-thin">
|
||||
{activeCollection?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-2 text-neutral"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title={t("more")}
|
||||
>
|
||||
<i className="bi-three-dots text-xl" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
sideOffset={4}
|
||||
align="end"
|
||||
className="bg-base-200 border border-neutral-content rounded-box p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
for (const link of links) {
|
||||
if (link.url) window.open(link.url, "_blank");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i className="bi-box-arrow-up-right" />
|
||||
{t("open_all_links")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{permissions === true && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEditCollectionModal(true)}
|
||||
>
|
||||
<i className="bi-pencil-square" />
|
||||
{t("edit_collection_info")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
<i className="bi-globe" />
|
||||
{permissions === true
|
||||
? t("share_and_collaborate")
|
||||
: t("view_team")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{permissions === true && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<i className="bi-folder-plus" />
|
||||
{t("create_subcollection")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCollectionModal(true)}
|
||||
className="text-error"
|
||||
>
|
||||
{permissions === true ? (
|
||||
<>
|
||||
<i className="bi-trash" />
|
||||
{t("delete_collection")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi-box-arrow-left" />
|
||||
{t("leave_collection")}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeCollection && (
|
||||
<div className="min-w-[15rem]">
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
className="flex items-center px-1 py-1 rounded-full cursor-pointer hover:bg-base-content/20 transition-colors duration-200"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id && (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
)}
|
||||
{activeCollection.members
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
key={i}
|
||||
src={e.user.image ? e.user.image : undefined}
|
||||
name={e.user.name}
|
||||
className="-ml-3"
|
||||
/>
|
||||
);
|
||||
})
|
||||
.slice(0, 3)}
|
||||
{activeCollection.members.length - 3 > 0 && (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{activeCollection.members.length - 3}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral text-sm ml-2">
|
||||
{activeCollection.members.length > 0
|
||||
? activeCollection.members.length === 1
|
||||
? t("by_author_and_other", {
|
||||
author: collectionOwner.name,
|
||||
count: activeCollection.members.length,
|
||||
})
|
||||
: t("by_author_and_others", {
|
||||
author: collectionOwner.name,
|
||||
count: activeCollection.members.length,
|
||||
})
|
||||
: t("by_author", { author: collectionOwner.name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeCollection?.description && (
|
||||
<p>{activeCollection.description}</p>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{collections.some((e) => e.parentId === activeCollection?.id) && (
|
||||
<>
|
||||
<PageHeader
|
||||
icon="bi-folder"
|
||||
title={t("collections")}
|
||||
description={t(
|
||||
collections.filter((e) => e.parentId === activeCollection?.id)
|
||||
.length === 1
|
||||
? "showing_count_result"
|
||||
: "showing_count_results",
|
||||
{
|
||||
count: collections.filter(
|
||||
(e) => e.parentId === activeCollection?.id
|
||||
).length,
|
||||
}
|
||||
)}
|
||||
className="scale-90 w-fit"
|
||||
/>
|
||||
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
{collections
|
||||
.filter((e) => e.parentId === activeCollection?.id)
|
||||
.map((e) => (
|
||||
<CollectionCard key={e.id} collection={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? editMode
|
||||
: undefined
|
||||
}
|
||||
setEditMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? setEditMode
|
||||
: undefined
|
||||
}
|
||||
links={links}
|
||||
>
|
||||
{collections.some((e) => e.parentId === activeCollection?.id) ? (
|
||||
<PageHeader
|
||||
icon={"bi-link-45deg"}
|
||||
title={t("links")}
|
||||
description={
|
||||
activeCollection?._count?.links === 1
|
||||
? t("showing_count_result", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
: t("showing_count_results", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
{activeCollection && (
|
||||
<div className="flex gap-3 items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCollection.icon ? (
|
||||
<Icon
|
||||
icon={activeCollection.icon}
|
||||
size={45}
|
||||
weight={
|
||||
(activeCollection.iconWeight || "regular") as IconWeight
|
||||
}
|
||||
className="scale-90 w-fit"
|
||||
color={activeCollection.color}
|
||||
/>
|
||||
) : (
|
||||
<p>
|
||||
{activeCollection?._count?.links === 1
|
||||
? t("showing_count_result", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
: t("showing_count_results", {
|
||||
count: activeCollection?._count?.links,
|
||||
})}
|
||||
</p>
|
||||
<i
|
||||
className="bi-folder-fill text-3xl"
|
||||
style={{ color: activeCollection.color }}
|
||||
/>
|
||||
)}
|
||||
</LinkListOptions>
|
||||
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && <NoLinksFound />}
|
||||
<p className="sm:text-3xl text-2xl w-full py-1 break-words hyphens-auto font-thin">
|
||||
{activeCollection?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-2 text-neutral"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title={t("more")}
|
||||
>
|
||||
<i className="bi-three-dots text-xl" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
sideOffset={4}
|
||||
align="end"
|
||||
className="bg-base-200 border border-neutral-content rounded-box p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
for (const link of links) {
|
||||
if (link.url) window.open(link.url, "_blank");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i className="bi-box-arrow-up-right" />
|
||||
{t("open_all_links")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{permissions === true && (
|
||||
<DropdownMenuItem onClick={() => setEditCollectionModal(true)}>
|
||||
<i className="bi-pencil-square" />
|
||||
{t("edit_collection_info")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
<i className="bi-globe" />
|
||||
{permissions === true
|
||||
? t("share_and_collaborate")
|
||||
: t("view_team")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{permissions === true && (
|
||||
<DropdownMenuItem onClick={() => setNewCollectionModal(true)}>
|
||||
<i className="bi-folder-plus" />
|
||||
{t("create_subcollection")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCollectionModal(true)}
|
||||
className="text-error"
|
||||
>
|
||||
{permissions === true ? (
|
||||
<>
|
||||
<i className="bi-trash" />
|
||||
{t("delete_collection")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi-box-arrow-left" />
|
||||
{t("leave_collection")}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{activeCollection && (
|
||||
<>
|
||||
{editCollectionModal && (
|
||||
<EditCollectionModal
|
||||
onClose={() => setEditCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeCollection && (
|
||||
<div className="min-w-[15rem]">
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
className="flex items-center px-1 py-1 rounded-full cursor-pointer hover:bg-base-content/20 transition-colors duration-200"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id && (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
)}
|
||||
{activeCollection.members
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
key={i}
|
||||
src={e.user.image ? e.user.image : undefined}
|
||||
name={e.user.name}
|
||||
className="-ml-3"
|
||||
/>
|
||||
);
|
||||
})
|
||||
.slice(0, 3)}
|
||||
{activeCollection.members.length - 3 > 0 && (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{activeCollection.members.length - 3}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral text-sm ml-2">
|
||||
{activeCollection.members.length > 0
|
||||
? activeCollection.members.length === 1
|
||||
? t("by_author_and_other", {
|
||||
author: collectionOwner.name,
|
||||
count: activeCollection.members.length,
|
||||
})
|
||||
: t("by_author_and_others", {
|
||||
author: collectionOwner.name,
|
||||
count: activeCollection.members.length,
|
||||
})
|
||||
: t("by_author", { author: collectionOwner.name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeCollection?.description && <p>{activeCollection.description}</p>}
|
||||
|
||||
<Separator />
|
||||
|
||||
{collections.some((e) => e.parentId === activeCollection?.id) && (
|
||||
<>
|
||||
<PageHeader
|
||||
icon="bi-folder"
|
||||
title={t("collections")}
|
||||
description={t(
|
||||
collections.filter((e) => e.parentId === activeCollection?.id)
|
||||
.length === 1
|
||||
? "showing_count_result"
|
||||
: "showing_count_results",
|
||||
{
|
||||
count: collections.filter(
|
||||
(e) => e.parentId === activeCollection?.id
|
||||
).length,
|
||||
}
|
||||
)}
|
||||
{editCollectionSharingModal && (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal
|
||||
onClose={() => setNewCollectionModal(false)}
|
||||
parent={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{deleteCollectionModal && (
|
||||
<DeleteCollectionModal
|
||||
onClose={() => setDeleteCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
className="scale-90 w-fit"
|
||||
/>
|
||||
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
{collections
|
||||
.filter((e) => e.parentId === activeCollection?.id)
|
||||
.map((e) => (
|
||||
<CollectionCard key={e.id} collection={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? editMode
|
||||
: undefined
|
||||
}
|
||||
setEditMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? setEditMode
|
||||
: undefined
|
||||
}
|
||||
links={links}
|
||||
>
|
||||
{collections.some((e) => e.parentId === activeCollection?.id) ? (
|
||||
<PageHeader
|
||||
icon={"bi-link-45deg"}
|
||||
title={t("links")}
|
||||
description={
|
||||
activeCollection?._count?.links === 1
|
||||
? t("showing_count_result", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
: t("showing_count_results", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
}
|
||||
className="scale-90 w-fit"
|
||||
/>
|
||||
) : (
|
||||
<p>
|
||||
{activeCollection?._count?.links === 1
|
||||
? t("showing_count_result", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
: t("showing_count_results", {
|
||||
count: activeCollection?._count?.links,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
</LinkListOptions>
|
||||
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && <NoLinksFound />}
|
||||
{activeCollection && (
|
||||
<>
|
||||
{editCollectionModal && (
|
||||
<EditCollectionModal
|
||||
onClose={() => setEditCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{editCollectionSharingModal && (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal
|
||||
onClose={() => setNewCollectionModal(false)}
|
||||
parent={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{deleteCollectionModal && (
|
||||
<DeleteCollectionModal
|
||||
onClose={() => setDeleteCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CollectionCard from "@/components/CollectionCard";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ReactElement, useMemo, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import { useSession } from "next-auth/react";
|
||||
import SortDropdown from "@/components/SortDropdown";
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Collections() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: collections = [], isLoading } = useCollections();
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
@@ -53,102 +54,106 @@ export default function Collections() {
|
||||
const [newCollectionModal, setNewCollectionModal] = useState(false);
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("collections")}
|
||||
description={t("collections_you_own")}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<i className="bi-plus-lg text-xl text-neutral"></i>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("new_collection")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<div className="relative mt-2">
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} />
|
||||
</div>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("collections")}
|
||||
description={t("collections_you_own")}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<i className="bi-plus-lg text-xl text-neutral"></i>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("new_collection")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<div className="relative mt-2">
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && collections && !collections[0] ? (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
|
||||
{!isLoading && collections && !collections[0] ? (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
|
||||
>
|
||||
<p className="text-center text-xl">
|
||||
{t("create_your_first_collection")}
|
||||
</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("create_your_first_collection_desc")}
|
||||
</p>
|
||||
<Button
|
||||
className="mx-auto mt-5"
|
||||
variant={"accent"}
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<p className="text-center text-xl">
|
||||
{t("create_your_first_collection")}
|
||||
</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("create_your_first_collection_desc")}
|
||||
</p>
|
||||
<Button
|
||||
className="mx-auto mt-5"
|
||||
variant={"accent"}
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<i className="bi-plus-lg text-xl mr-2" />
|
||||
<i className="bi-plus-lg text-xl mr-2" />
|
||||
{t("new_collection")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
{sortedCollections
|
||||
.filter((e) => e.ownerId === data?.user.id && e.parentId === null)
|
||||
.map((e) => (
|
||||
<CollectionCard key={e.id} collection={e} />
|
||||
))}
|
||||
|
||||
<div
|
||||
className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content p-5 bg-base-200 self-stretch min-h-[12rem] rounded-xl cursor-pointer flex flex-col gap-4 justify-center items-center group"
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<p className="group-hover:opacity-0 duration-100">
|
||||
{t("new_collection")}
|
||||
</Button>
|
||||
</p>
|
||||
<i className="bi-plus-lg text-5xl group-hover:text-7xl group-hover:-mt-10 text-primary drop-shadow duration-100"></i>
|
||||
</div>
|
||||
) : (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] && (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("other_collections")}
|
||||
description={t("other_collections_desc")}
|
||||
/>
|
||||
|
||||
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
{sortedCollections
|
||||
.filter((e) => e.ownerId === data?.user.id && e.parentId === null)
|
||||
.filter((e) => e.ownerId !== data?.user.id)
|
||||
.map((e) => (
|
||||
<CollectionCard key={e.id} collection={e} />
|
||||
))}
|
||||
|
||||
<div
|
||||
className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content p-5 bg-base-200 self-stretch min-h-[12rem] rounded-xl cursor-pointer flex flex-col gap-4 justify-center items-center group"
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<p className="group-hover:opacity-0 duration-100">
|
||||
{t("new_collection")}
|
||||
</p>
|
||||
<i className="bi-plus-lg text-5xl group-hover:text-7xl group-hover:-mt-10 text-primary drop-shadow duration-100"></i>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] && (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("other_collections")}
|
||||
description={t("other_collections_desc")}
|
||||
/>
|
||||
|
||||
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
{sortedCollections
|
||||
.filter((e) => e.ownerId !== data?.user.id)
|
||||
.map((e) => (
|
||||
<CollectionCard key={e.id} collection={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||
)}
|
||||
</MainLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ReactElement, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -34,8 +34,9 @@ import { useUpdateLink } from "@linkwarden/router/links";
|
||||
import usePinLink from "@/lib/client/pinLink";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "./_app";
|
||||
|
||||
export default function Dashboard() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: collections = [] } = useCollections();
|
||||
const {
|
||||
@@ -45,19 +46,8 @@ export default function Dashboard() {
|
||||
...dashboardData
|
||||
} = useDashboardData();
|
||||
|
||||
/**
|
||||
* Get a combined list of all links, including those from collections.
|
||||
* Dupplications are fine since this is used for finding dragged link
|
||||
*/
|
||||
const allLinks = useMemo(() => {
|
||||
const _collectionLinks = Object.values(collectionLinks).flat();
|
||||
return [...links, ..._collectionLinks];
|
||||
}, [collectionLinks, links]);
|
||||
|
||||
const { data: tags = [] } = useTags();
|
||||
const { data: user } = useUser();
|
||||
const pinLink = usePinLink();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
const [activeLink, setActiveLink] =
|
||||
@@ -148,214 +138,79 @@ export default function Dashboard() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { over, active } = event;
|
||||
if (!over || !activeLink) return;
|
||||
|
||||
const targetSectionId = over.id as string;
|
||||
const collectionId = over.data.current?.id as number;
|
||||
const collectionName = over.data.current?.name as string;
|
||||
const ownerId = over.data.current?.ownerId as number;
|
||||
|
||||
const isFromRecentSection = active.data.current?.dashboardType === "recent";
|
||||
|
||||
// Immediately hide the drag overlay
|
||||
setActiveLink(null);
|
||||
if (over.data.current?.type === "tag") {
|
||||
const isTagAlreadyExists = activeLink.tags.some(
|
||||
(tag) => tag.name === over.data.current?.name
|
||||
);
|
||||
if (isTagAlreadyExists) {
|
||||
toast.error(t("tag_already_added"));
|
||||
return;
|
||||
}
|
||||
// to match the tags structure required to update the link
|
||||
const allTags: { name: string }[] = activeLink.tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
}));
|
||||
const newTags = [...allTags, { name: over.data.current?.name as string }];
|
||||
const updatedLink = {
|
||||
...activeLink,
|
||||
tags: newTags as any,
|
||||
};
|
||||
const load = toast.loading(t("updating"));
|
||||
await updateLink.mutateAsync(updatedLink, {
|
||||
onSettled: (_, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) {
|
||||
// If there's an error, invalidate queries to restore the original state
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle pinning the link
|
||||
if (targetSectionId === "pinned-links-section") {
|
||||
if (Array.isArray(activeLink.pinnedBy) && !activeLink.pinnedBy.length) {
|
||||
// optimistically update the link's pinned state
|
||||
const updatedLink = {
|
||||
...activeLink,
|
||||
pinnedBy: [user?.id],
|
||||
};
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.links) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
links: oldData.links.map((link: any) =>
|
||||
link.id === updatedLink.id ? updatedLink : link
|
||||
),
|
||||
};
|
||||
});
|
||||
pinLink(activeLink);
|
||||
}
|
||||
// Handle moving the link to a different collection
|
||||
} else if (activeLink.collection.id !== collectionId) {
|
||||
// Optimistically update the link's collection immediately
|
||||
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
|
||||
...activeLink,
|
||||
collection: {
|
||||
id: collectionId,
|
||||
name: collectionName,
|
||||
ownerId,
|
||||
},
|
||||
};
|
||||
|
||||
// Optimistically update the dashboard data cache
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.links) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
links: oldData.links.map((link: any) =>
|
||||
link.id === updatedLink.id ? updatedLink : link
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Optimistically update the collection links cache
|
||||
if (collectionId) {
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.collectionLinks) return oldData;
|
||||
|
||||
const oldCollectionId = activeLink.collection.id!;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
collectionLinks: {
|
||||
...oldData.collectionLinks,
|
||||
// Remove from old collection
|
||||
[oldCollectionId]: (
|
||||
oldData.collectionLinks[oldCollectionId] || []
|
||||
).filter((link: any) => link.id !== updatedLink.id),
|
||||
// Add to new collection
|
||||
[collectionId]: [
|
||||
...(oldData.collectionLinks[collectionId] || []),
|
||||
updatedLink,
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
await updateLink.mutateAsync(updatedLink, {
|
||||
onSettled: (_, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) {
|
||||
// If there's an error, invalidate queries to restore the original state
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (isFromRecentSection) {
|
||||
// show error if link is dragged from recent section to the target collection which it already belongs to
|
||||
toast.error(t("link_already_in_collection"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
onDragEnd={handleDragEnd}
|
||||
links={allLinks}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="bi-house-fill text-primary" />
|
||||
<p className="font-thin">{t("dashboard")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DashboardLayoutDropdown />
|
||||
<ViewDropdown
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
dashboard
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="bi-house-fill text-primary" />
|
||||
<p className="font-thin">{t("dashboard")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DashboardLayoutDropdown />
|
||||
<ViewDropdown
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
dashboard
|
||||
/>
|
||||
</div>
|
||||
{orderedSections[0] ? (
|
||||
orderedSections?.map((section, i) => (
|
||||
<Section
|
||||
key={i}
|
||||
sectionData={section}
|
||||
t={t}
|
||||
collection={collections.find(
|
||||
(c) => c.id === section.collectionId
|
||||
)}
|
||||
collectionLinks={
|
||||
section.collectionId
|
||||
? collectionLinks[section.collectionId]
|
||||
: []
|
||||
}
|
||||
links={links}
|
||||
tags={tags}
|
||||
numberOfLinks={numberOfLinks}
|
||||
collectionsLength={collections.length}
|
||||
numberOfPinnedLinks={numberOfPinnedLinks}
|
||||
dashboardData={dashboardData}
|
||||
setNewLinkModal={setNewLinkModal}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
<div className="xl:flex flex flex-col sm:grid grid-cols-2 gap-4 xl:flex-row xl:justify-evenly xl:w-full">
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
</div>
|
||||
<div className="skeleton h-full"></div>
|
||||
<div className="skeleton h-full"></div>
|
||||
<div className="skeleton h-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{orderedSections[0] ? (
|
||||
orderedSections?.map((section, i) => (
|
||||
<Section
|
||||
key={i}
|
||||
sectionData={section}
|
||||
t={t}
|
||||
collection={collections.find(
|
||||
(c) => c.id === section.collectionId
|
||||
)}
|
||||
collectionLinks={
|
||||
section.collectionId
|
||||
? collectionLinks[section.collectionId]
|
||||
: []
|
||||
}
|
||||
links={links}
|
||||
tags={tags}
|
||||
numberOfLinks={numberOfLinks}
|
||||
collectionsLength={collections.length}
|
||||
numberOfPinnedLinks={numberOfPinnedLinks}
|
||||
dashboardData={dashboardData}
|
||||
setNewLinkModal={setNewLinkModal}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
<div className="xl:flex flex flex-col sm:grid grid-cols-2 gap-4 xl:flex-row xl:justify-evenly xl:w-full">
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
</div>
|
||||
<div className="skeleton h-full"></div>
|
||||
<div className="skeleton h-full"></div>
|
||||
<div className="skeleton h-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSurveyModal && (
|
||||
<SurveyModal
|
||||
submit={submitSurvey}
|
||||
onClose={() => {
|
||||
setShowsSurveyModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newLinkModal && (
|
||||
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||
)}
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
{showSurveyModal && (
|
||||
<SurveyModal
|
||||
submit={submitSurvey}
|
||||
onClose={() => {
|
||||
setShowsSurveyModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import NoLinksFound from "@/components/NoLinksFound";
|
||||
import { useLinks, useUpdateLink } from "@linkwarden/router/links";
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
Sort,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { Sort, ViewMode } from "@linkwarden/types";
|
||||
import { useRouter } from "next/router";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import clsx from "clsx";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Index() {
|
||||
const [activeLink, setActiveLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags | null>(null);
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
@@ -40,50 +34,43 @@ export default function Index() {
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<div className={clsx("flex items-center gap-3")}>
|
||||
<i
|
||||
className={`bi-link-45deg text-primary text-3xl drop-shadow`}
|
||||
></i>
|
||||
<div>
|
||||
<p className="text-2xl capitalize font-thin">
|
||||
{t("all_links")}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm">{t("all_links_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LinkListOptions>
|
||||
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<NoLinksFound text={t("you_have_not_added_any_links")} />
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<div className={clsx("flex items-center gap-3")}>
|
||||
<i className={`bi-link-45deg text-primary text-3xl drop-shadow`}></i>
|
||||
<div>
|
||||
<p className="text-2xl capitalize font-thin">{t("all_links")}</p>
|
||||
<p className="text-xs sm:text-sm">{t("all_links_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
</LinkListOptions>
|
||||
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<NoLinksFound text={t("you_have_not_added_any_links")} />
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import React, { useState } from "react";
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
Sort,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
import { Sort, ViewMode } from "@linkwarden/types";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function PinnedLinks() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
@@ -28,67 +24,60 @@ export default function PinnedLinks() {
|
||||
pinnedOnly: true,
|
||||
});
|
||||
|
||||
const [activeLink, setActiveLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<PageHeader
|
||||
icon={"bi-pin-angle"}
|
||||
title={t("pinned")}
|
||||
description={t("pinned_links_desc")}
|
||||
/>
|
||||
</LinkListOptions>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<PageHeader
|
||||
icon={"bi-pin-angle"}
|
||||
title={t("pinned")}
|
||||
description={t("pinned_links_desc")}
|
||||
/>
|
||||
</LinkListOptions>
|
||||
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5 text-primary drop-shadow"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5c0 .276-.224 1.5-.5 1.5s-.5-1.224-.5-1.5V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A6 6 0 0 1 5 6.708V2.277a3 3 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354m1.58 1.408-.002-.001zm-.002-.001.002.001A.5.5 0 0 1 6 2v5a.5.5 0 0 1-.276.447h-.002l-.012.007-.054.03a5 5 0 0 0-.827.58c-.318.278-.585.596-.725.936h7.792c-.14-.34-.407-.658-.725-.936a5 5 0 0 0-.881-.61l-.012-.006h-.002A.5.5 0 0 1 10 7V2a.5.5 0 0 1 .295-.458 1.8 1.8 0 0 0 .351-.271c.08-.08.155-.17.214-.271H5.14q.091.15.214.271a1.8 1.8 0 0 0 .37.282" />
|
||||
</svg>
|
||||
<p className="text-center text-xl">
|
||||
{t("pin_favorite_links_here")}
|
||||
</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("pin_favorite_links_here_desc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5 text-primary drop-shadow"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5c0 .276-.224 1.5-.5 1.5s-.5-1.224-.5-1.5V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A6 6 0 0 1 5 6.708V2.277a3 3 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354m1.58 1.408-.002-.001zm-.002-.001.002.001A.5.5 0 0 1 6 2v5a.5.5 0 0 1-.276.447h-.002l-.012.007-.054.03a5 5 0 0 0-.827.58c-.318.278-.585.596-.725.936h7.792c-.14-.34-.407-.658-.725-.936a5 5 0 0 0-.881-.61l-.012-.006h-.002A.5.5 0 0 1 10 7V2a.5.5 0 0 1 .295-.458 1.8 1.8 0 0 0 .351-.271c.08-.08.155-.17.214-.271H5.14q.091.15.214.271a1.8 1.8 0 0 0 .37.282" />
|
||||
</svg>
|
||||
<p className="text-center text-xl">{t("pin_favorite_links_here")}</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("pin_favorite_links_here_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import React, { useState, FormEvent } from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
@@ -306,7 +306,6 @@ export default function PublicCollections() {
|
||||
}) as any
|
||||
}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, FormEvent } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getLogins } from "./api/v1/logins";
|
||||
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "./_app";
|
||||
|
||||
export default function Search() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -41,38 +41,35 @@ export default function Search() {
|
||||
});
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<PageHeader icon={"bi-search"} title={t("search_results")} />
|
||||
</LinkListOptions>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<PageHeader icon={"bi-search"} title={t("search_results")} />
|
||||
</LinkListOptions>
|
||||
|
||||
{!data.isLoading && links && !links[0] && <p>{t("nothing_found")}</p>}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
{!data.isLoading && links && !links[0] && <p>{t("nothing_found")}</p>}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import React, { useState } from "react";
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import NewTokenModal from "@/components/ModalContent/NewTokenModal";
|
||||
import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal";
|
||||
import { AccessToken } from "@linkwarden/prisma/client";
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function AccessTokens() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const [newTokenModal, setNewTokenModal] = useState(false);
|
||||
const [revokeTokenModal, setRevokeTokenModal] = useState(false);
|
||||
const [selectedToken, setSelectedToken] = useState<AccessToken | null>(null);
|
||||
@@ -29,7 +30,7 @@ export default function AccessTokens() {
|
||||
const { data: tokens = [] } = useTokens();
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("access_tokens")}
|
||||
</p>
|
||||
@@ -124,8 +125,14 @@ export default function AccessTokens() {
|
||||
activeToken={selectedToken}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, ChangeEvent } from "react";
|
||||
import { useState, useEffect, ChangeEvent, ReactElement } from "react";
|
||||
import { AccountSettings } from "@linkwarden/types";
|
||||
import { toast } from "react-hot-toast";
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Account() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const [emailChangeVerificationModal, setEmailChangeVerificationModal] =
|
||||
useState(false);
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
@@ -145,7 +146,7 @@ export default function Account() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("accountSettings")}
|
||||
</p>
|
||||
@@ -358,8 +359,14 @@ export default function Account() {
|
||||
newEmail={user.email || ""}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -2,7 +2,7 @@ import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useRouter } from "next/router";
|
||||
import InviteModal from "@/components/ModalContent/InviteModal";
|
||||
import { User as U } from "@linkwarden/prisma/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUsers } from "@linkwarden/router/users";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
interface User extends U {
|
||||
subscriptions: {
|
||||
@@ -34,7 +35,7 @@ type UserModal = {
|
||||
|
||||
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
|
||||
|
||||
export default function Billing() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -74,7 +75,7 @@ export default function Billing() {
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("billing_settings")}
|
||||
</p>
|
||||
@@ -289,8 +290,14 @@ export default function Billing() {
|
||||
userId={deleteUserModal.userId}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useState } from "react";
|
||||
import { ReactElement, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { useTranslation } from "next-i18next";
|
||||
@@ -7,8 +7,9 @@ import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUpdateUser, useUser } from "@linkwarden/router/user";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Password() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
@@ -52,7 +53,7 @@ export default function Password() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("change_password")}
|
||||
</p>
|
||||
@@ -90,8 +91,14 @@ export default function Password() {
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, ReactElement } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import Checkbox from "@/components/Checkbox";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Preference() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useLocalSettingsStore();
|
||||
const updateUserPreference = useUpdateUserPreference();
|
||||
@@ -187,7 +188,7 @@ export default function Preference() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">{t("preference")}</p>
|
||||
|
||||
<Separator className="my-3" />
|
||||
@@ -613,8 +614,14 @@ export default function Preference() {
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -3,14 +3,15 @@ import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useRssSubscriptions } from "@linkwarden/router/rss";
|
||||
import DeleteRssSubscriptionModal from "@/components/ModalContent/DeleteRssSubscriptionModal";
|
||||
import { useState } from "react";
|
||||
import { ReactElement, useState } from "react";
|
||||
import { RssSubscription } from "@linkwarden/prisma/client";
|
||||
import NewRssSubscriptionModal from "@/components/ModalContent/NewRssSubscriptionModal";
|
||||
import { useConfig } from "@linkwarden/router/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function RssSubscriptions() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: rssSubscriptions = [] } = useRssSubscriptions();
|
||||
|
||||
@@ -27,7 +28,7 @@ export default function RssSubscriptions() {
|
||||
const { data: config } = useConfig();
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("rss_subscriptions")}
|
||||
</p>
|
||||
@@ -96,8 +97,14 @@ export default function RssSubscriptions() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import ConfirmationModal from "@/components/ConfirmationModal";
|
||||
import { LinkArchiveActionSchemaType } from "@linkwarden/lib/schemaValidation";
|
||||
import toast from "react-hot-toast";
|
||||
import { useArchiveAction } from "@linkwarden/router/links";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Worker() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const archiveAction = useArchiveAction();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -52,7 +53,7 @@ export default function Worker() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">{t("worker")}</p>
|
||||
|
||||
<Separator className="my-3" />
|
||||
@@ -114,8 +115,14 @@ export default function Worker() {
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { signOut, useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import { Plan } from "@linkwarden/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { FormEvent, ReactElement, useEffect, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Index() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -145,123 +145,109 @@ export default function Index() {
|
||||
);
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex gap-2 items-center font-thin">
|
||||
<i className="bi-hash text-primary text-3xl" />
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex gap-2 items-center font-thin">
|
||||
<i className="bi-hash text-primary text-3xl" />
|
||||
|
||||
{renameTag ? (
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
className="sm:text-3xl text-xl bg-transparent h-10 w-3/4 outline-none border-b border-b-neutral-content"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={submit}>
|
||||
<i className="bi-check2 text-neutral text-xl" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={cancelUpdateTag}
|
||||
{renameTag ? (
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
className="sm:text-3xl text-xl bg-transparent h-10 w-3/4 outline-none border-b border-b-neutral-content"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={submit}>
|
||||
<i className="bi-check2 text-neutral text-xl" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={cancelUpdateTag}>
|
||||
<i className="bi-x text-neutral text-xl" />
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="sm:text-3xl text-xl">{activeTag?.name}</p>
|
||||
<div className="relative">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t("more")}>
|
||||
<i className="bi-three-dots text-xl text-neutral" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
sideOffset={4}
|
||||
align={
|
||||
activeTag?.name.length && activeTag?.name.length > 8
|
||||
? "end"
|
||||
: "start"
|
||||
}
|
||||
className="bg-base-200 border border-neutral-content rounded-box p-1"
|
||||
>
|
||||
<i className="bi-x text-neutral text-xl" />
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="sm:text-3xl text-xl">{activeTag?.name}</p>
|
||||
<div className="relative">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t("more")}>
|
||||
<i className="bi-three-dots text-xl text-neutral" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuItem onClick={() => setRenameTag(true)}>
|
||||
<i className="bi-pencil-square" />
|
||||
{t("rename_tag")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuContent
|
||||
sideOffset={4}
|
||||
align={
|
||||
activeTag?.name.length && activeTag?.name.length > 8
|
||||
? "end"
|
||||
: "start"
|
||||
}
|
||||
className="bg-base-200 border border-neutral-content rounded-box p-1"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setRenameTag(true)}>
|
||||
<i className="bi-pencil-square" />
|
||||
{t("rename_tag")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={remove}
|
||||
className="text-error"
|
||||
>
|
||||
<i className="bi-trash" />
|
||||
{t("delete_tag")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkListOptions>
|
||||
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
|
||||
>
|
||||
<p className="text-center text-xl">
|
||||
{t("this_tag_has_no_links")}
|
||||
</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("this_tag_has_no_links_desc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuItem onClick={remove} className="text-error">
|
||||
<i className="bi-trash" />
|
||||
{t("delete_tag")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => setBulkDeleteLinksModal(false)}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal onClose={() => setBulkEditLinksModal(false)} />
|
||||
)}
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
</LinkListOptions>
|
||||
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
useData={data}
|
||||
/>
|
||||
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
|
||||
>
|
||||
<p className="text-center text-xl">{t("this_tag_has_no_links")}</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("this_tag_has_no_links_desc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal onClose={() => setBulkDeleteLinksModal(false)} />
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal onClose={() => setBulkEditLinksModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ReactElement, useMemo, useState } from "react";
|
||||
import NewTagModal from "@/components/ModalContent/NewTagModal";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import BulkDeleteTagsModal from "@/components/ModalContent/BulkDeleteTagsModal";
|
||||
import MergeTagsModal from "@/components/ModalContent/MergeTagsModal";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
enum TagSort {
|
||||
DateNewestFirst = 0,
|
||||
@@ -32,7 +33,7 @@ enum TagSort {
|
||||
LinkCountLowHigh = 5,
|
||||
}
|
||||
|
||||
export default function Tags() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: tags = [], isLoading } = useTags();
|
||||
|
||||
@@ -72,194 +73,192 @@ export default function Tags() {
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader icon={"bi-hash"} title={t("tags")} />
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader icon={"bi-hash"} title={t("tags")} />
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setNewTagModal(true)}
|
||||
>
|
||||
<i className="bi-plus-lg text-xl text-neutral"></i>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("new_tag")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
className={editMode ? "bg-primary/20 hover:bg-primary/20" : ""}
|
||||
>
|
||||
<i className="bi-pencil-fill text-neutral text-xl" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<i className="bi-chevron-expand text-neutral text-xl"></i>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={4} align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortBy.toString()}
|
||||
onValueChange={(v) => setSortBy(Number(v) as TagSort)}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value={TagSort.DateNewestFirst.toString()}
|
||||
>
|
||||
{t("date_newest_first")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem
|
||||
value={TagSort.DateOldestFirst.toString()}
|
||||
>
|
||||
{t("date_oldest_first")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem value={TagSort.NameAZ.toString()}>
|
||||
{t("name_az")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem value={TagSort.NameZA.toString()}>
|
||||
{t("name_za")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem
|
||||
value={TagSort.LinkCountHighLow.toString()}
|
||||
>
|
||||
{t("link_count_high_low")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem
|
||||
value={TagSort.LinkCountLowHigh.toString()}
|
||||
>
|
||||
{t("link_count_low_high")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tags && editMode && tags.length > 0 && (
|
||||
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||
<div className="flex gap-3 ml-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => {
|
||||
if (selectedTags.length === tags.length) setSelectedTags([]);
|
||||
else setSelectedTags(tags.map((t) => t.id));
|
||||
}}
|
||||
checked={selectedTags.length === tags.length && tags.length > 0}
|
||||
/>
|
||||
{selectedTags.length > 0 ? (
|
||||
<span>
|
||||
{selectedTags.length === 1
|
||||
? t("tag_selected")
|
||||
: t("tags_selected", { count: selectedTags.length })}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("nothing_selected")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMergeTagsModal(true);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setNewTagModal(true)}
|
||||
disabled={selectedTags.length < 2}
|
||||
>
|
||||
<i className="bi-plus-lg text-xl text-neutral"></i>
|
||||
<i className="bi-intersect" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("new_tag")}</p>
|
||||
<TooltipContent>
|
||||
<p>{t("merge_tags")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
setBulkDeleteModal(true);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={selectedTags.length === 0}
|
||||
>
|
||||
<i className="bi-trash text-error" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p> {t("delete")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
className={editMode ? "bg-primary/20 hover:bg-primary/20" : ""}
|
||||
>
|
||||
<i className="bi-pencil-fill text-neutral text-xl" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<i className="bi-chevron-expand text-neutral text-xl"></i>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={4} align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortBy.toString()}
|
||||
onValueChange={(v) => setSortBy(Number(v) as TagSort)}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value={TagSort.DateNewestFirst.toString()}
|
||||
>
|
||||
{t("date_newest_first")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem
|
||||
value={TagSort.DateOldestFirst.toString()}
|
||||
>
|
||||
{t("date_oldest_first")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem value={TagSort.NameAZ.toString()}>
|
||||
{t("name_az")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem value={TagSort.NameZA.toString()}>
|
||||
{t("name_za")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem
|
||||
value={TagSort.LinkCountHighLow.toString()}
|
||||
>
|
||||
{t("link_count_high_low")}
|
||||
</DropdownMenuRadioItem>
|
||||
|
||||
<DropdownMenuRadioItem
|
||||
value={TagSort.LinkCountLowHigh.toString()}
|
||||
>
|
||||
{t("link_count_low_high")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags && editMode && tags.length > 0 && (
|
||||
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||
<div className="flex gap-3 ml-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => {
|
||||
if (selectedTags.length === tags.length) setSelectedTags([]);
|
||||
else setSelectedTags(tags.map((t) => t.id));
|
||||
}}
|
||||
checked={selectedTags.length === tags.length && tags.length > 0}
|
||||
/>
|
||||
{selectedTags.length > 0 ? (
|
||||
<span>
|
||||
{selectedTags.length === 1
|
||||
? t("tag_selected")
|
||||
: t("tags_selected", { count: selectedTags.length })}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("nothing_selected")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMergeTagsModal(true);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={selectedTags.length < 2}
|
||||
>
|
||||
<i className="bi-intersect" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("merge_tags")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
setBulkDeleteModal(true);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={selectedTags.length === 0}
|
||||
>
|
||||
<i className="bi-trash text-error" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p> {t("delete")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid 2xl:grid-cols-6 xl:grid-cols-5 sm:grid-cols-3 grid-cols-2 gap-5">
|
||||
{sortedTags.map((tag: any) => (
|
||||
<TagCard
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
selected={selectedTags.includes(tag.id)}
|
||||
editMode={editMode}
|
||||
onSelect={(id: number) => {
|
||||
console.log(id);
|
||||
if (selectedTags.includes(id))
|
||||
setSelectedTags((prev) => prev.filter((t) => t !== id));
|
||||
else setSelectedTags((prev) => [...prev, id]);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isLoading && tags && !tags[0] && (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
|
||||
>
|
||||
<p className="text-center text-xl">{t("create_your_first_tag")}</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("create_your_first_tag_desc")}
|
||||
</p>
|
||||
<Button
|
||||
className="mx-auto mt-5"
|
||||
variant={"accent"}
|
||||
onClick={() => setNewTagModal(true)}
|
||||
>
|
||||
<i className="bi-plus-lg text-xl mr-2" />
|
||||
{t("new_tag")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid 2xl:grid-cols-6 xl:grid-cols-5 sm:grid-cols-3 grid-cols-2 gap-5">
|
||||
{sortedTags.map((tag: any) => (
|
||||
<TagCard
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
selected={selectedTags.includes(tag.id)}
|
||||
editMode={editMode}
|
||||
onSelect={(id: number) => {
|
||||
console.log(id);
|
||||
if (selectedTags.includes(id))
|
||||
setSelectedTags((prev) => prev.filter((t) => t !== id));
|
||||
else setSelectedTags((prev) => [...prev, id]);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isLoading && tags && !tags[0] && (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
|
||||
>
|
||||
<p className="text-center text-xl">{t("create_your_first_tag")}</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("create_your_first_tag_desc")}
|
||||
</p>
|
||||
<Button
|
||||
className="mx-auto mt-5"
|
||||
variant={"accent"}
|
||||
onClick={() => setNewTagModal(true)}
|
||||
>
|
||||
<i className="bi-plus-lg text-xl mr-2" />
|
||||
{t("new_tag")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newTagModal && <NewTagModal onClose={() => setNewTagModal(false)} />}
|
||||
{bulkDeleteModal && (
|
||||
<BulkDeleteTagsModal
|
||||
@@ -281,8 +280,14 @@ export default function Tags() {
|
||||
setSelectedTags={setSelectedTags}
|
||||
/>
|
||||
)}
|
||||
</MainLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,44 +1,42 @@
|
||||
import { create } from "zustand";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
|
||||
type ResponseObject = {
|
||||
ok: boolean;
|
||||
data: object | string;
|
||||
};
|
||||
|
||||
type LinkStore = {
|
||||
selectedLinks: LinkIncludingShortenedCollectionAndTags[];
|
||||
setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void;
|
||||
updateLinks: (
|
||||
links: LinkIncludingShortenedCollectionAndTags[],
|
||||
removePreviousTags: boolean,
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
"tags" | "collectionId"
|
||||
>
|
||||
) => Promise<ResponseObject>;
|
||||
selectedIds: Record<number, true>;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
clearSelected: () => void;
|
||||
setSelected: (ids: number[]) => void;
|
||||
selectionCount: number;
|
||||
};
|
||||
|
||||
const useLinkStore = create<LinkStore>()((set) => ({
|
||||
selectedLinks: [],
|
||||
setSelectedLinks: (links) => set({ selectedLinks: links }),
|
||||
updateLinks: async (links, removePreviousTags, newData) => {
|
||||
const response = await fetch("/api/v1/links", {
|
||||
body: JSON.stringify({ links, removePreviousTags, newData }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
const useLinkStore = create<LinkStore>()((set, get) => ({
|
||||
selectedIds: {},
|
||||
|
||||
const data = await response.json();
|
||||
isSelected: (id) => !!get().selectedIds[id],
|
||||
|
||||
if (response.ok) {
|
||||
// Update the selected links with the new data
|
||||
}
|
||||
toggleSelected: (id) =>
|
||||
set((state) => {
|
||||
const next = { ...state.selectedIds };
|
||||
|
||||
return { ok: response.ok, data: data.response };
|
||||
},
|
||||
if (next[id]) {
|
||||
delete next[id];
|
||||
return { selectedIds: next, selectionCount: state.selectionCount - 1 };
|
||||
} else {
|
||||
next[id] = true;
|
||||
return { selectedIds: next, selectionCount: state.selectionCount + 1 };
|
||||
}
|
||||
}),
|
||||
|
||||
clearSelected: () => set({ selectedIds: {}, selectionCount: 0 }),
|
||||
|
||||
setSelected: (ids) =>
|
||||
set(() => {
|
||||
const next: Record<number, true> = {};
|
||||
for (let i = 0; i < ids.length; i++) next[ids[i]] = true;
|
||||
return { selectedIds: next, selectionCount: Object.keys(next).length };
|
||||
}),
|
||||
|
||||
selectionCount: 0,
|
||||
}));
|
||||
|
||||
export default useLinkStore;
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
predefinedTagsPrompt,
|
||||
} from "./prompts";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { generateObject, LanguageModelV1 } from "ai";
|
||||
import { generateObject } from "ai";
|
||||
import { LanguageModelV2 } from "@ai-sdk/provider";
|
||||
import {
|
||||
createOpenAICompatible,
|
||||
OpenAICompatibleProviderSettings,
|
||||
@@ -15,14 +16,14 @@ import { azure } from "@ai-sdk/azure";
|
||||
import { z } from "zod";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { createOllama } from "ollama-ai-provider";
|
||||
import { createOllama } from "ollama-ai-provider-v2";
|
||||
import { titleCase } from "@linkwarden/lib";
|
||||
|
||||
// Function to concat /api with the base URL properly
|
||||
const ensureValidURL = (base: string, path: string) =>
|
||||
`${base.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
||||
|
||||
const getAIModel = (): LanguageModelV1 => {
|
||||
const getAIModel = (): LanguageModelV2 => {
|
||||
if (process.env.OPENAI_API_KEY && process.env.OPENAI_MODEL) {
|
||||
let config: OpenAICompatibleProviderSettings = {
|
||||
baseURL:
|
||||
@@ -51,16 +52,14 @@ const getAIModel = (): LanguageModelV1 => {
|
||||
),
|
||||
});
|
||||
|
||||
return ollama(process.env.OLLAMA_MODEL, {
|
||||
structuredOutputs: true,
|
||||
});
|
||||
return ollama(process.env.OLLAMA_MODEL);
|
||||
}
|
||||
if (process.env.OPENROUTER_API_KEY && process.env.OPENROUTER_MODEL) {
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
});
|
||||
|
||||
return openrouter(process.env.OPENROUTER_MODEL) as LanguageModelV1;
|
||||
return openrouter(process.env.OPENROUTER_MODEL) as LanguageModelV2;
|
||||
}
|
||||
if (process.env.PERPLEXITY_API_KEY) {
|
||||
return perplexity(process.env.PERPLEXITY_MODEL || "sonar-pro");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from "child_process";
|
||||
import { createFile } from "@linkwarden/filesystem";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { Link } from "@prisma/client";
|
||||
import { Link } from "@linkwarden/prisma/client";
|
||||
|
||||
export default async function handleMonolith(
|
||||
link: Link,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { JSDOM } from "jsdom";
|
||||
import DOMPurify from "dompurify";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { createFile } from "@linkwarden/filesystem";
|
||||
import { Link } from "@prisma/client";
|
||||
import { Link } from "@linkwarden/prisma/client";
|
||||
|
||||
const handleReadability = async (
|
||||
content: string,
|
||||
@@ -19,7 +19,7 @@ const handleReadability = async (
|
||||
|
||||
const article = new Readability(dom.window.document).parse();
|
||||
const articleText = article?.textContent
|
||||
.replace(/ +(?= )/g, "") // strip out multiple spaces
|
||||
?.replace(/ +(?= )/g, "") // strip out multiple spaces
|
||||
.replace(/(\r\n|\n|\r)/gm, " ") // strip out line breaks
|
||||
.slice(0, TEXT_CONTENT_LIMIT ? TEXT_CONTENT_LIMIT : undefined); // limit characters if TEXT_CONTENT_LIMIT is defined
|
||||
|
||||
|
||||
@@ -9,30 +9,30 @@
|
||||
"start": "tsx index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "1.1.5",
|
||||
"@ai-sdk/azure": "1.1.5",
|
||||
"@ai-sdk/openai-compatible": "^0.2.13",
|
||||
"@ai-sdk/perplexity": "1.1.9",
|
||||
"@ai-sdk/anthropic": "2.0.56",
|
||||
"@ai-sdk/azure": "2.0.88",
|
||||
"@ai-sdk/openai-compatible": "1.0.29",
|
||||
"@ai-sdk/perplexity": "2.0.22",
|
||||
"@linkwarden/filesystem": "*",
|
||||
"@linkwarden/lib": "*",
|
||||
"@linkwarden/prisma": "*",
|
||||
"@linkwarden/types": "*",
|
||||
"@mozilla/readability": "^0.4.4",
|
||||
"@openrouter/ai-sdk-provider": "^0.4.3",
|
||||
"ai": "^4.3.9",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"ai": "^5.0.113",
|
||||
"axios": "^1.5.1",
|
||||
"dompurify": "^3.0.6",
|
||||
"dompurify": "^3.2.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jsdom": "^22.1.0",
|
||||
"meilisearch": "^0.48.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"ollama-ai-provider": "^1.2.0",
|
||||
"ollama-ai-provider-v2": "^1.5.5",
|
||||
"playwright": "^1.55.0",
|
||||
"rss-parser": "^3.13.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"tsx": "^4.19.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { startIndexing } from "./workers/linkIndexing";
|
||||
import { linkProcessing } from "./workers/linkProcessing";
|
||||
import { migrationWorker } from "./workers/migrationWorker";
|
||||
import { startRSSPolling } from "./workers/rssPolling";
|
||||
import { trialEndEmailWorker } from "./workers/trialEndEmailWorker";
|
||||
|
||||
@@ -7,6 +8,8 @@ const workerIntervalInSeconds =
|
||||
Number(process.env.ARCHIVE_SCRIPT_INTERVAL) || 10;
|
||||
|
||||
async function init() {
|
||||
await migrationWorker();
|
||||
|
||||
console.log("\x1b[34m%s\x1b[0m", "Initializing the worker...");
|
||||
startRSSPolling();
|
||||
linkProcessing(workerIntervalInSeconds);
|
||||
|
||||
80
apps/worker/workers/migrationWorker.ts
Normal file
80
apps/worker/workers/migrationWorker.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { AppMigrationStatus } from "@linkwarden/prisma/client";
|
||||
|
||||
export async function migrationWorker() {
|
||||
console.log("\x1b[34m%s\x1b[0m", "Checking for migrations...");
|
||||
|
||||
// go through all the migrations one by one in order, first see where it needs to start
|
||||
try {
|
||||
const dbMigrations = await prisma.appMigration.findMany();
|
||||
|
||||
const statusByName = new Map(dbMigrations.map((m) => [m.name, m.status]));
|
||||
|
||||
// sort by id
|
||||
const ordered = [...migrations].sort((a, b) => a.id - b.id);
|
||||
|
||||
// find the first migration that's not APPLIED
|
||||
const firstIdx = ordered.findIndex(
|
||||
(m) => statusByName.get(m.name) !== AppMigrationStatus.APPLIED
|
||||
);
|
||||
|
||||
if (firstIdx === -1) {
|
||||
// console.log("\x1b[32m%s\x1b[0m", "No pending migrations."); // Uncomment later
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = firstIdx; i < ordered.length; i++) {
|
||||
const m = ordered[i];
|
||||
const status = statusByName.get(m.name);
|
||||
|
||||
if (status === AppMigrationStatus.APPLIED) continue;
|
||||
|
||||
console.log("\x1b[34m%s\x1b[0m", `Applying ${m.name}...`);
|
||||
|
||||
await prisma.appMigration.upsert({
|
||||
where: { name: m.name },
|
||||
create: { name: m.name, status: AppMigrationStatus.PENDING },
|
||||
update: { status: AppMigrationStatus.PENDING, finishedAt: null },
|
||||
});
|
||||
|
||||
try {
|
||||
await m.run();
|
||||
|
||||
await prisma.appMigration.update({
|
||||
where: { name: m.name },
|
||||
data: { status: AppMigrationStatus.APPLIED, finishedAt: new Date() },
|
||||
});
|
||||
|
||||
statusByName.set(m.name, AppMigrationStatus.APPLIED);
|
||||
console.log("\x1b[32m%s\x1b[0m", `Applied ${m.name}`);
|
||||
} catch (err) {
|
||||
await prisma.appMigration.update({
|
||||
where: { name: m.name },
|
||||
data: { status: AppMigrationStatus.FAILED, finishedAt: new Date() },
|
||||
});
|
||||
|
||||
console.error("\x1b[31m%s\x1b[0m", `FAILED ${m.name}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\x1b[32m%s\x1b[0m", "All migrations applied.");
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
type AppMigrationDef = {
|
||||
id: number;
|
||||
name: string;
|
||||
run: () => Promise<void>;
|
||||
};
|
||||
|
||||
const migrations: AppMigrationDef[] = [
|
||||
// {
|
||||
// id: 1,
|
||||
// name: "0001_first_migration",
|
||||
// run: async () => {},
|
||||
// },
|
||||
// to create a new `AppMigrationDef`, make sure to have the `id` and the `name` field to be unique (incremental id)
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkwarden",
|
||||
"packageManager": "yarn@1.22.0",
|
||||
"packageManager": "yarn@4.12.0+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -11,6 +11,7 @@
|
||||
"web:dev": "dotenv -- yarn workspace @linkwarden/web dev",
|
||||
"web:build": "dotenv -- yarn workspace @linkwarden/web build",
|
||||
"web:start": "dotenv -- yarn workspace @linkwarden/web start",
|
||||
"update:browserslist-db": "npx update-browserslist-db@latest",
|
||||
"worker:dev": "dotenv -- yarn workspace @linkwarden/worker dev",
|
||||
"worker:start": "dotenv -- yarn workspace @linkwarden/worker start",
|
||||
"concurrently:dev": "concurrently \"dotenv -- yarn workspace @linkwarden/web dev\" \"dotenv -- yarn workspace @linkwarden/worker dev\"",
|
||||
@@ -23,8 +24,8 @@
|
||||
"postinstall": "yarn workspace @linkwarden/web run postinstall && patch-package"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.3.20",
|
||||
"@types/react-dom": "18.3.7"
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^8.0.0"
|
||||
|
||||
@@ -24,7 +24,7 @@ export const generatePreview = async (
|
||||
1024 * 1024 * Number(process.env.PREVIEW_MAX_BUFFER || 10)
|
||||
) {
|
||||
console.log("Error generating preview: Buffer size exceeded");
|
||||
prisma.link.update({
|
||||
await prisma.link.update({
|
||||
where: { id: linkId },
|
||||
data: {
|
||||
preview: "unavailable",
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"@linkwarden/filesystem": "*",
|
||||
"@linkwarden/prisma": "*",
|
||||
"@linkwarden/types": "*",
|
||||
"@linkwarden/filesystem": "*",
|
||||
"clsx": "^2.1.1",
|
||||
"jimp": "^0.22.10",
|
||||
"meilisearch": "^0.48.2",
|
||||
"nodemailer": "^7.0.11",
|
||||
"rss-parser": "^3.13.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"nodemailer": "^6.9.3",
|
||||
"@types/nodemailer": "^6.4.8"
|
||||
"tailwind-merge": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nodemailer": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RssSubscription } from "@prisma/client";
|
||||
import { RssSubscription } from "@linkwarden/prisma/client";
|
||||
import { hasPassedLimit } from "./verifyCapacity";
|
||||
import Parser from "rss-parser";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
|
||||
@@ -28,7 +28,7 @@ export const VerifyEmailSchema = z.object({
|
||||
|
||||
export const PostTokenSchema = z.object({
|
||||
name: z.string().max(50),
|
||||
expires: z.nativeEnum(TokenExpiry),
|
||||
expires: z.enum(TokenExpiry),
|
||||
});
|
||||
|
||||
export type PostTokenSchemaType = z.infer<typeof PostTokenSchema>;
|
||||
@@ -82,21 +82,21 @@ export const UpdateUserSchema = () => {
|
||||
archiveAsPDF: z.boolean().optional(),
|
||||
archiveAsReadable: z.boolean().optional(),
|
||||
archiveAsWaybackMachine: z.boolean().optional(),
|
||||
aiTaggingMethod: z.nativeEnum(AiTaggingMethod).optional(),
|
||||
aiTaggingMethod: z.enum(AiTaggingMethod).optional(),
|
||||
aiPredefinedTags: z.array(z.string().max(20).trim()).max(20).optional(),
|
||||
aiTagExistingLinks: z.boolean().optional(),
|
||||
locale: z.string().max(20).optional(),
|
||||
isPrivate: z.boolean().optional(),
|
||||
preventDuplicateLinks: z.boolean().optional(),
|
||||
collectionOrder: z.array(z.number()).optional(),
|
||||
linksRouteTo: z.nativeEnum(LinksRouteTo).optional(),
|
||||
linksRouteTo: z.enum(LinksRouteTo).optional(),
|
||||
whitelistedUsers: z.array(z.string().max(50)).optional(),
|
||||
referredBy: z.string().max(100).nullish(),
|
||||
});
|
||||
};
|
||||
|
||||
export const UpdateUserPreferenceSchema = z.object({
|
||||
theme: z.nativeEnum(Theme).optional(),
|
||||
theme: z.enum(Theme).optional(),
|
||||
readableFontFamily: z.string().trim().max(100).optional(),
|
||||
readableFontSize: z.string().trim().max(100).optional(),
|
||||
readableLineHeight: z.string().trim().max(100).optional(),
|
||||
@@ -106,11 +106,11 @@ export const UpdateUserPreferenceSchema = z.object({
|
||||
// archiveAsPDF: z.boolean().optional(),
|
||||
// archiveAsReadable: z.boolean().optional(),
|
||||
// archiveAsWaybackMachine: z.boolean().optional(),
|
||||
// aiTaggingMethod: z.nativeEnum(AiTaggingMethod).optional(),
|
||||
// aiTaggingMethod: z.enum(AiTaggingMethod).optional(),
|
||||
// aiPredefinedTags: z.array(z.string().max(20).trim()).max(20).optional(),
|
||||
// aiTagExistingLinks: z.boolean().optional(),
|
||||
// preventDuplicateLinks: z.boolean().optional(),
|
||||
// linksRouteTo: z.nativeEnum(LinksRouteTo).optional(),
|
||||
// linksRouteTo: z.enum(LinksRouteTo).optional(),
|
||||
});
|
||||
|
||||
export type UpdateUserPreferenceSchemaType = z.infer<
|
||||
@@ -205,7 +205,7 @@ export const UploadFileSchema = z.object({
|
||||
),
|
||||
id: z.number().optional(),
|
||||
url: z.string().trim().max(2048).url().optional(),
|
||||
format: z.nativeEnum(ArchivedFormat),
|
||||
format: z.enum(ArchivedFormat),
|
||||
});
|
||||
|
||||
export const PostCollectionSchema = z.object({
|
||||
@@ -304,7 +304,7 @@ export type LinkArchiveActionSchemaType = z.infer<
|
||||
|
||||
export const UpdateDashboardLayoutSchema = z.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(DashboardSectionType),
|
||||
type: z.enum(DashboardSectionType),
|
||||
collectionId: z.number().optional(),
|
||||
enabled: z.boolean(),
|
||||
order: z.number().optional(),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AppMigrationStatus" AS ENUM ('APPLIED', 'PENDING', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AppMigration" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"status" "AppMigrationStatus" NOT NULL,
|
||||
"finishedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AppMigration_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AppMigration_name_key" ON "AppMigration"("name");
|
||||
@@ -15,7 +15,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"prisma": "^5.21.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
|
||||
@@ -290,4 +290,18 @@ enum DashboardSectionType {
|
||||
RECENT_LINKS
|
||||
PINNED_LINKS
|
||||
COLLECTION
|
||||
}
|
||||
}
|
||||
|
||||
model AppMigration {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
status AppMigrationStatus
|
||||
finishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum AppMigrationStatus {
|
||||
APPLIED
|
||||
PENDING
|
||||
FAILED
|
||||
}
|
||||
|
||||
@@ -20,27 +20,38 @@ import getFormatFromContentType from "@linkwarden/lib/getFormatFromContentType";
|
||||
import getLinkTypeFromFormat from "@linkwarden/lib/getLinkTypeFromFormat";
|
||||
|
||||
const useLinks = (params: LinkRequestQuery = {}, auth?: MobileAuth) => {
|
||||
const queryParamsObject = {
|
||||
sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0,
|
||||
collectionId: params.collectionId,
|
||||
tagId: params.tagId,
|
||||
pinnedOnly: params.pinnedOnly ?? undefined,
|
||||
searchQueryString: params.searchQueryString,
|
||||
} as LinkRequestQuery;
|
||||
const sort =
|
||||
params.sort ??
|
||||
(typeof window !== "undefined"
|
||||
? Number(window.localStorage.getItem("sortBy"))
|
||||
: 0) ??
|
||||
0;
|
||||
|
||||
const queryString = buildQueryString(queryParamsObject);
|
||||
const queryString = useMemo(() => {
|
||||
return buildQueryString({
|
||||
sort,
|
||||
collectionId: params.collectionId,
|
||||
tagId: params.tagId,
|
||||
pinnedOnly: params.pinnedOnly ?? undefined,
|
||||
searchQueryString: params.searchQueryString,
|
||||
});
|
||||
}, [
|
||||
sort,
|
||||
params.collectionId,
|
||||
params.tagId,
|
||||
params.pinnedOnly,
|
||||
params.searchQueryString,
|
||||
]);
|
||||
|
||||
const { data, ...rest } = useFetchLinks(queryString, auth);
|
||||
const query = useFetchLinks(queryString, auth);
|
||||
|
||||
const links = useMemo(() => {
|
||||
return data?.pages?.flatMap((page) => page?.links ?? []) ?? [];
|
||||
}, [data]);
|
||||
|
||||
const memoizedData = useMemo(() => ({ ...data, ...rest }), [data, rest]);
|
||||
return query.data?.pages?.flatMap((p) => p.links ?? []) ?? [];
|
||||
}, [query.dataUpdatedAt]);
|
||||
|
||||
return {
|
||||
links,
|
||||
data: memoizedData,
|
||||
data: query,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -83,12 +94,7 @@ const useFetchLinks = (params: string, auth?: MobileAuth) => {
|
||||
},
|
||||
initialPageParam: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.nextCursor === null) {
|
||||
return undefined;
|
||||
}
|
||||
return lastPage.nextCursor;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
enabled: status === "authenticated",
|
||||
});
|
||||
};
|
||||
@@ -494,7 +500,7 @@ const useBulkEditLinks = () => {
|
||||
newData,
|
||||
removePreviousTags,
|
||||
}: {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
links: Pick<LinkIncludingShortenedCollectionAndTags, "id">[];
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
"tags" | "collectionId"
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
"@linkwarden/prisma": "*",
|
||||
"@linkwarden/types": "*",
|
||||
"@tanstack/react-query": "^5.51.15",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"@tanstack/react-query-devtools": "^5.51.15",
|
||||
"next": "13.4.12",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-i18next": "^15.3.0",
|
||||
"next-auth": "^4.22.1"
|
||||
"react-hot-toast": "^2.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "18.3.1"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Collection, Link, Tag, User } from "@prisma/client";
|
||||
import { Collection, Link, Tag, User } from "@linkwarden/prisma/client";
|
||||
import Stripe from "stripe";
|
||||
|
||||
type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
|
||||
|
||||
Reference in New Issue
Block a user