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:
Daniel
2025-12-21 18:09:05 -05:00
committed by GitHub
parent 9faf9d844e
commit 389e5df117
86 changed files with 23653 additions and 16100 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -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 \

View File

@@ -1 +0,0 @@
node-linker=hoisted

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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"

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) : [];

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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)} />

View File

@@ -57,6 +57,7 @@ export default function ProfilePhoto({
draggable={false}
onError={() => setImage("")}
className="aspect-square rounded-full"
unoptimized
/>
</div>
</div>

View File

@@ -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
/>
)}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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) {

View File

@@ -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 } }

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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>

View File

@@ -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);
}

View 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)
);
}

View File

@@ -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);
}

View 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();
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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";

View File

@@ -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";

View File

@@ -306,7 +306,6 @@ export default function PublicCollections() {
}) as any
}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
{!data.isLoading && links && !links[0] && (

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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");

View File

@@ -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,

View File

@@ -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

View File

@@ -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",

View File

@@ -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);

View 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)
];

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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";

View File

@@ -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(),

View File

@@ -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");

View File

@@ -15,7 +15,6 @@
},
"devDependencies": {
"@types/node": "^22.14.0",
"prisma": "^5.21.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
},

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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> &

35729
yarn.lock

File diff suppressed because it is too large Load Diff