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