diff --git a/.env.sample b/.env.sample index 6cee384..f6b4d41 100644 --- a/.env.sample +++ b/.env.sample @@ -1,11 +1,12 @@ -NEXTAUTH_SECRET=very_sensitive_secret NEXTAUTH_URL=http://localhost:3000/api/v1/auth +NEXTAUTH_SECRET= # Manual installation database settings -DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden +# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden +DATABASE_URL= # Docker installation database settings -POSTGRES_PASSWORD=super_secret_password +POSTGRES_PASSWORD= # Additional Optional Settings PAGINATION_TAKE_COUNT= @@ -15,13 +16,25 @@ NEXT_PUBLIC_DISABLE_REGISTRATION= NEXT_PUBLIC_CREDENTIALS_ENABLED= DISABLE_NEW_SSO_USERS= RE_ARCHIVE_LIMIT= -NEXT_PUBLIC_MAX_FILE_SIZE= MAX_LINKS_PER_USER= ARCHIVE_TAKE_COUNT= BROWSER_TIMEOUT= IGNORE_UNAUTHORIZED_CA= IGNORE_HTTPS_ERRORS= IGNORE_URL_SIZE_LIMIT= +NEXT_PUBLIC_DEMO= +NEXT_PUBLIC_DEMO_USERNAME= +NEXT_PUBLIC_DEMO_PASSWORD= +NEXT_PUBLIC_ADMIN= +NEXT_PUBLIC_MAX_FILE_BUFFER= +MONOLITH_MAX_BUFFER= +MONOLITH_CUSTOM_OPTIONS= +PDF_MAX_BUFFER= +SCREENSHOT_MAX_BUFFER= +READABILITY_MAX_BUFFER= +PREVIEW_MAX_BUFFER= +IMPORT_LIMIT= +MAX_WORKERS= # AWS S3 Settings SPACES_KEY= @@ -35,6 +48,7 @@ SPACES_FORCE_PATH_STYLE= NEXT_PUBLIC_EMAIL_PROVIDER= EMAIL_FROM= EMAIL_SERVER= +BASE_URL= # Proxy settings PROXY= @@ -46,9 +60,9 @@ PROXY_BYPASS= PDF_MARGIN_TOP= PDF_MARGIN_BOTTOM= -# -# SSO Providers -# +################# +# SSO Providers # +################# # 42 School NEXT_PUBLIC_FORTYTWO_ENABLED= @@ -76,6 +90,12 @@ AUTH0_ISSUER= AUTH0_CLIENT_SECRET= AUTH0_CLIENT_ID= +# Authelia +NEXT_PUBLIC_AUTHELIA_ENABLED="" +AUTHELIA_CLIENT_ID="" +AUTHELIA_CLIENT_SECRET="" +AUTHELIA_WELLKNOWN_URL="" + # Authentik NEXT_PUBLIC_AUTHENTIK_ENABLED= AUTHENTIK_CUSTOM_NAME= @@ -83,12 +103,25 @@ AUTHENTIK_ISSUER= AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= +# Azure AD B2C +NEXT_PUBLIC_AZURE_AD_B2C_ENABLED= +AZURE_AD_B2C_TENANT_NAME= +AZURE_AD_B2C_CLIENT_ID= +AZURE_AD_B2C_CLIENT_SECRET= +AZURE_AD_B2C_PRIMARY_USER_FLOW= + +# Azure AD +NEXT_PUBLIC_AZURE_AD_ENABLED= +AZURE_AD_CLIENT_ID= +AZURE_AD_CLIENT_SECRET= +AZURE_AD_TENANT_ID= + # Battle.net NEXT_PUBLIC_BATTLENET_ENABLED= BATTLENET_CUSTOM_NAME= BATTLENET_CLIENT_ID= BATTLENET_CLIENT_SECRET= -BATLLENET_ISSUER= +BATTLENET_ISSUER= # Box NEXT_PUBLIC_BOX_ENABLED= @@ -177,8 +210,8 @@ FUSIONAUTH_TENANT_ID= # GitHub NEXT_PUBLIC_GITHUB_ENABLED= GITHUB_CUSTOM_NAME= -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= +GITHUB_ID= +GITHUB_SECRET= # GitLab NEXT_PUBLIC_GITLAB_ENABLED= diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml new file mode 100644 index 0000000..4edd49f --- /dev/null +++ b/.github/workflows/playwright-tests.yml @@ -0,0 +1,143 @@ +name: Linkwarden Playwright Tests + +on: + push: + branches: + - main + - qacomet/** + pull_request: + workflow_dispatch: + +env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: password + PGDATABASE: postgres + + TEST_POSTGRES_USER: test_linkwarden_user + TEST_POSTGRES_PASSWORD: password + TEST_POSTGRES_DATABASE: test_linkwarden_db + TEST_POSTGRES_DATABASE_TEMPLATE: test_linkwarden_db_template + TEST_POSTGRES_HOST: localhost + TEST_POSTGREST_PORT: 5432 + PRODUCTION_POSTGRES_DATABASE: linkwarden_db + + NEXTAUTH_SECRET: very_sensitive_secret + NEXTAUTH_URL: http://localhost:3000/api/v1/auth + + # Manual installation database settings + DATABASE_URL: postgresql://test_linkwarden_user:password@localhost:5432/test_linkwarden_db + + # Docker installation database settings + POSTGRES_PASSWORD: password + + TEST_USERNAME: test-user + TEST_PASSWORD: password + +jobs: + playwright-test-runner: + strategy: + matrix: + test_case: ['@login'] + timeout-minutes: 20 + runs-on: + - ubuntu-22.04 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: 'yarn' + + - name: Initialize PostgreSQL + run: | + echo "Initializing Databases" + psql -h localhost -U postgres -d postgres -c "CREATE USER ${{ env.TEST_POSTGRES_USER }} WITH PASSWORD '${{ env.TEST_POSTGRES_PASSWORD }}';" + psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};" + + - name: Install packages + run: yarn install -y + + - name: Cache playwright dependencies + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: | + ffmpeg fonts-freefont-ttf fonts-ipafont-gothic fonts-tlwg-loma-otf + fonts-unifont fonts-wqy-zenhei gstreamer1.0-libav gstreamer1.0-plugins-bad + gstreamer1.0-plugins-base gstreamer1.0-plugins-good libaa1 libass9 + libasyncns0 libavc1394-0 libavcodec58 libavdevice58 libavfilter7 + libavformat58 libavutil56 libbluray2 libbs2b0 libcaca0 libcdio-cdda2 + libcdio-paranoia2 libcdio19 libcdparanoia0 libchromaprint1 libcodec2-1.0 + libdc1394-25 libdca0 libdecor-0-0 libdv4 libdvdnav4 libdvdread8 libegl-mesa0 + libegl1 libevdev2 libevent-2.1-7 libfaad2 libffi7 libflac8 libflite1 + libfluidsynth3 libfreeaptx0 libgles2 libgme0 libgsm1 libgssdp-1.2-0 + libgstreamer-gl1.0-0 libgstreamer-plugins-bad1.0-0 + libgstreamer-plugins-base1.0-0 libgstreamer-plugins-good1.0-0 libgupnp-1.2-1 + libgupnp-igd-1.0-4 libharfbuzz-icu0 libhyphen0 libiec61883-0 + libinstpatch-1.0-2 libjack-jackd2-0 libkate1 libldacbt-enc2 liblilv-0-0 + libltc11 libmanette-0.2-0 libmfx1 libmjpegutils-2.1-0 libmodplug1 + libmp3lame0 libmpcdec6 libmpeg2encpp-2.1-0 libmpg123-0 libmplex2-2.1-0 + libmysofa1 libnice10 libnotify4 libopenal-data libopenal1 libopengl0 + libopenh264-6 libopenmpt0 libopenni2-0 libopus0 liborc-0.4-0 + libpocketsphinx3 libpostproc55 libpulse0 libqrencode4 libraw1394-11 + librubberband2 libsamplerate0 libsbc1 libsdl2-2.0-0 libserd-0-0 libshine3 + libshout3 libsndfile1 libsndio7.0 libsord-0-0 libsoundtouch1 libsoup-3.0-0 + libsoup-3.0-common libsoxr0 libspandsp2 libspeex1 libsphinxbase3 + libsratom-0-0 libsrt1.4-gnutls libsrtp2-1 libssh-gcrypt-4 libswresample3 + libswscale5 libtag1v5 libtag1v5-vanilla libtheora0 libtwolame0 libudfread0 + libv4l-0 libv4lconvert0 libva-drm2 libva-x11-2 libva2 libvdpau1 + libvidstab1.1 libvisual-0.4-0 libvo-aacenc0 libvo-amrwbenc0 libvorbisenc2 + libvpx7 libwavpack1 libwebrtc-audio-processing1 libwildmidi2 libwoff1 + libx264-163 libxcb-shape0 libxv1 libxvidcore4 libzbar0 libzimg2 + libzvbi-common libzvbi0 libzxingcore1 ocl-icd-libopencl1 timgm6mb-soundfont + xfonts-cyrillic xfonts-encodings xfonts-scalable xfonts-utils + + - name: Cache playwright browsers + id: cache-playwright + uses: actions/cache@v4 + with: + path: ~/.cache/ + key: ${{ runner.os }}-playwright-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install playwright + if: steps.cache-playwright.outputs.cache-hit != 'true' + run: yarn playwright install --with-deps + + - name: Setup project + run: | + yarn prisma generate + yarn build + yarn prisma migrate deploy + + - name: Start linkwarden server and worker + run: yarn start & + + - name: Run Tests + run: npx playwright test --grep ${{ matrix.test_case }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: test-results + retention-days: 30 diff --git a/.github/workflows/release-container.yml b/.github/workflows/release-container.yml index e994913..5f89745 100644 --- a/.github/workflows/release-container.yml +++ b/.github/workflows/release-container.yml @@ -27,7 +27,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to the Container registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -40,7 +40,7 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: context: . push: true diff --git a/.gitignore b/.gitignore index 906d2a6..2161174 100644 --- a/.gitignore +++ b/.gitignore @@ -42,9 +42,15 @@ prisma/dev.db # tests /tests /test-results/ +/blob-report/ /playwright-report/ /playwright/.cache/ +/playwright/.auth/ # docker pgdata -certificates \ No newline at end of file +certificates +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.prettierignore b/.prettierignore index 3742dae..eb2aa38 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,6 @@ node_modules .next -public +/public *.lock *.log diff --git a/.vscode/settings.json b/.vscode/settings.json index 0967ef4..b446483 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,6 @@ -{} +{ + "tailwindCSS.experimental.classRegex": [ + ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ] +} diff --git a/Dockerfile b/Dockerfile index a6d6129..d999194 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,16 @@ -FROM node:18.18-bullseye-slim +# Stage: monolith-builder +# Purpose: Uses the Rust image to build monolith +# Notes: +# - Fine to leave extra here, as only the resulting binary is copied out +FROM docker.io/rust:1.80-bullseye AS monolith-builder + +RUN set -eux && cargo install --locked monolith + +# Stage: main-app +# Purpose: Compiles the frontend and +# Notes: +# - Nothing extra should be left here. All commands should cleanup +FROM node:18.18-bullseye-slim AS main-app ARG DEBIAN_FRONTEND=noninteractive @@ -8,10 +20,15 @@ WORKDIR /data COPY ./package.json ./yarn.lock ./playwright.config.ts ./ -# Increase timeout to pass github actions arm64 build -RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000 +RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \ + set -eux && \ + yarn install --network-timeout 10000000 -RUN npx playwright install-deps && \ +# Copy the compiled monolith binary from the builder stage +COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith + +RUN set -eux && \ + npx playwright install --with-deps chromium && \ apt-get clean && \ yarn cache clean @@ -20,4 +37,6 @@ COPY . . RUN yarn prisma generate && \ yarn build +EXPOSE 3000 + CMD yarn prisma migrate deploy && yarn start diff --git a/README.md b/README.md index cd3725a..e8651d4 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@

Linkwarden

+

Bookmark Preservation for Individuals and Teams

Discord -Twitter - -GitHub commits since latest release +Twitter
-[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Support ❤](https://github.com/linkwarden/linkwarden#support-) +[« LAUNCH DEMO »](https://demo.linkwarden.app) + +[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features)
@@ -24,7 +25,7 @@ The objective is to organize useful webpages and articles you find across the we Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly. > [!TIP] -> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits.
Your subscription supports our hosting infrastructure and ongoing development.
Alternatively, if you prefer [self-hosting](https://docs.linkwarden.app/self-hosting/installation) Linkwarden, no problem! You'll still have access to all the premium features. +> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits.
Your subscription supports our hosting infrastructure and ongoing development.
Alternatively, if you prefer self-hosting Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation). @@ -57,7 +58,7 @@ We've forked the old version from the current repository into [this repo](https: ## Features -- 📸 Auto capture a screenshot, PDF, and readable view of each webpage. +- 📸 Auto capture a screenshot, PDF, single html file, and readable view of each webpage. - 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional) - 📂 Organize links by collection, sub-collection, name, description and multiple tags. - 👥 Collaborate on gathering links in a collection. @@ -71,10 +72,14 @@ We've forked the old version from the current repository into [this repo](https: - ⬇️ Import and export your bookmarks. - 🔐 SSO integration. (Enterprise and Self-hosted users only) - 📦 Installable Progressive Web App (PWA). +- 🍏 iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00). - 🍎 iOS Shortcut to save links to Linkwarden. - 🔑 API keys. - ✅ Bulk actions. -- ✨ And so many more features! +- 👥 User administration. +- 🌐 Support for Other Languages (i18n). +- 📁 Image and PDF Uploads. +- ✨ And many more features. (Literally!) ## Like what we're doing? Give us a Star ⭐ @@ -98,7 +103,7 @@ We _usually_ go after the [popular suggestions](https://github.com/linkwarden/li Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1). -## Docs +## Documentation For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app). @@ -110,7 +115,7 @@ If you want to contribute, Thanks! Start by checking our [public roadmap](https: If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks! -## Support ❤ +## Support <3 Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well! diff --git a/components/AccentSubmitButton.tsx b/components/AccentSubmitButton.tsx deleted file mode 100644 index 1d87787..0000000 --- a/components/AccentSubmitButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -type Props = { - onClick?: Function; - label: string; - loading?: boolean; - className?: string; - type?: "button" | "submit" | "reset" | undefined; -}; - -export default function AccentSubmitButton({ - onClick, - label, - loading, - className, - type, -}: Props) { - return ( - - ); -} diff --git a/components/Announcement.tsx b/components/Announcement.tsx new file mode 100644 index 0000000..5486f8b --- /dev/null +++ b/components/Announcement.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import React, { MouseEventHandler } from "react"; +import { Trans } from "next-i18next"; + +type Props = { + toggleAnnouncementBar: MouseEventHandler; +}; + +export default function Announcement({ toggleAnnouncementBar }: Props) { + const announcementId = localStorage.getItem("announcementId"); + + return ( +
+
+ +

+ , + ]} + /> +

+ +
+
+ ); +} diff --git a/components/AnnouncementBar.tsx b/components/AnnouncementBar.tsx deleted file mode 100644 index 57b7e92..0000000 --- a/components/AnnouncementBar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Link from "next/link"; -import React, { MouseEventHandler } from "react"; - -type Props = { - toggleAnnouncementBar: MouseEventHandler; -}; - -export default function AnnouncementBar({ toggleAnnouncementBar }: Props) { - return ( -
-
-
- 🎉️ See what's new in{" "} - - Linkwarden v2.5 - - ! 🥳️ -
- - -
-
- ); -} diff --git a/components/ClickAwayHandler.tsx b/components/ClickAwayHandler.tsx index 63378a1..e8e8878 100644 --- a/components/ClickAwayHandler.tsx +++ b/components/ClickAwayHandler.tsx @@ -8,19 +8,38 @@ type Props = { onMount?: (rect: DOMRect) => void; }; +function getZIndex(element: HTMLElement): number { + let zIndex = 0; + while (element) { + const zIndexStyle = window + .getComputedStyle(element) + .getPropertyValue("z-index"); + const numericZIndex = Number(zIndexStyle); + if (zIndexStyle !== "auto" && !isNaN(numericZIndex)) { + zIndex = numericZIndex; + break; + } + element = element.parentElement as HTMLElement; + } + return zIndex; +} + function useOutsideAlerter( ref: RefObject, onClickOutside: Function ) { useEffect(() => { - function handleClickOutside(event: Event) { - if ( - ref.current && - !ref.current.contains(event.target as HTMLInputElement) - ) { - onClickOutside(event); + function handleClickOutside(event: MouseEvent) { + const clickedElement = event.target as HTMLElement; + if (ref.current && !ref.current.contains(clickedElement)) { + const refZIndex = getZIndex(ref.current); + const clickedZIndex = getZIndex(clickedElement); + if (clickedZIndex <= refZIndex) { + onClickOutside(event); + } } } + document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); diff --git a/components/CollectionCard.tsx b/components/CollectionCard.tsx index 1a6cc31..4ff9677 100644 --- a/components/CollectionCard.tsx +++ b/components/CollectionCard.tsx @@ -1,24 +1,28 @@ import Link from "next/link"; -import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; +import { + AccountSettings, + CollectionIncludingMembersAndLinkCount, +} from "@/types/global"; import React, { useEffect, useState } from "react"; import ProfilePhoto from "./ProfilePhoto"; import usePermissions from "@/hooks/usePermissions"; import useLocalSettingsStore from "@/store/localSettings"; import getPublicUserData from "@/lib/client/getPublicUserData"; -import useAccountStore from "@/store/account"; import EditCollectionModal from "./ModalContent/EditCollectionModal"; import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal"; import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal"; import { dropdownTriggerer } from "@/lib/client/utils"; +import { useTranslation } from "next-i18next"; +import { useUser } from "@/hooks/store/user"; -type Props = { +export default function CollectionCard({ + collection, +}: { collection: CollectionIncludingMembersAndLinkCount; - className?: string; -}; - -export default function CollectionCard({ collection, className }: Props) { +}) { + const { t } = useTranslation(); const { settings } = useLocalSettingsStore(); - const { account } = useAccountStore(); + const { data: user = {} } = useUser(); const formattedDate = new Date(collection.createdAt as string).toLocaleString( "en-US", @@ -31,28 +35,24 @@ export default function CollectionCard({ collection, className }: Props) { const permissions = usePermissions(collection.id as number); - const [collectionOwner, setCollectionOwner] = useState({ - id: null as unknown as number, - name: "", - username: "", - image: "", - archiveAsScreenshot: undefined as unknown as boolean, - archiveAsPDF: undefined as unknown as boolean, - }); + const [collectionOwner, setCollectionOwner] = useState< + Partial + >({}); useEffect(() => { const fetchOwner = async () => { - if (collection && collection.ownerId !== account.id) { + if (collection && collection.ownerId !== user.id) { const owner = await getPublicUserData(collection.ownerId as number); setCollectionOwner(owner); - } else if (collection && collection.ownerId === account.id) { + } else if (collection && collection.ownerId === user.id) { setCollectionOwner({ - id: account.id as number, - name: account.name, - username: account.username as string, - image: account.image as string, - archiveAsScreenshot: account.archiveAsScreenshot as boolean, - archiveAsPDF: account.archiveAsPDF as boolean, + id: user.id as number, + name: user.name, + username: user.username as string, + image: user.image as string, + archiveAsScreenshot: user.archiveAsScreenshot as boolean, + archiveAsMonolith: user.archiveAsMonolith as boolean, + archiveAsPDF: user.archiveAsPDF as boolean, }); } }; @@ -76,8 +76,8 @@ export default function CollectionCard({ collection, className }: Props) { > -
    - {permissions === true ? ( +
      + {permissions === true && (
    • - Edit Collection Info + {t("edit_collection_info")}
    • - ) : undefined} + )}
    • - {permissions === true ? "Share and Collaborate" : "View Team"} + {permissions === true + ? t("share_and_collaborate") + : t("view_team")}
    • @@ -111,8 +115,11 @@ export default function CollectionCard({ collection, className }: Props) { (document?.activeElement as HTMLElement)?.blur(); setDeleteCollectionModal(true); }} + className="whitespace-nowrap" > - {permissions === true ? "Delete Collection" : "Leave Collection"} + {permissions === true + ? t("delete_collection") + : t("leave_collection")}
    @@ -121,12 +128,12 @@ export default function CollectionCard({ collection, className }: Props) { className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full" onClick={() => setEditCollectionSharingModal(true)} > - {collectionOwner.id ? ( + {collectionOwner.id && ( - ) : undefined} + )} {collection.members .sort((a, b) => (a.userId as number) - (b.userId as number)) .map((e, i) => { @@ -140,13 +147,13 @@ export default function CollectionCard({ collection, className }: Props) { ); }) .slice(0, 3)} - {collection.members.length - 3 > 0 ? ( + {collection.members.length - 3 > 0 && (
    +{collection.members.length - 3}
    - ) : null} + )}
    - {collection.isPublic ? ( + {collection.isPublic && ( - ) : undefined} + )}
    - {editCollectionModal ? ( + {editCollectionModal && ( setEditCollectionModal(false)} activeCollection={collection} /> - ) : undefined} - {editCollectionSharingModal ? ( + )} + {editCollectionSharingModal && ( setEditCollectionSharingModal(false)} activeCollection={collection} /> - ) : undefined} - {deleteCollectionModal ? ( + )} + {deleteCollectionModal && ( setDeleteCollectionModal(false)} activeCollection={collection} /> - ) : undefined} + )}
    ); } diff --git a/components/CollectionListing.tsx b/components/CollectionListing.tsx index 171117b..3ff2415 100644 --- a/components/CollectionListing.tsx +++ b/components/CollectionListing.tsx @@ -9,66 +9,75 @@ import Tree, { TreeSourcePosition, TreeDestinationPosition, } from "@atlaskit/tree"; -import useCollectionStore from "@/store/collections"; import { Collection } from "@prisma/client"; import Link from "next/link"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { useRouter } from "next/router"; -import useAccountStore from "@/store/account"; import toast from "react-hot-toast"; +import { useTranslation } from "next-i18next"; +import { useCollections, useUpdateCollection } from "@/hooks/store/collections"; +import { useUpdateUser, useUser } from "@/hooks/store/user"; +import Icon from "./Icon"; +import { IconWeight } from "@phosphor-icons/react"; interface ExtendedTreeItem extends TreeItem { data: Collection; } const CollectionListing = () => { - const { collections, updateCollection } = useCollectionStore(); - const { account, updateAccount } = useAccountStore(); + const { t } = useTranslation(); + const updateCollection = useUpdateCollection(); + const { data: collections = [], isLoading } = useCollections(); + + const { data: user = {} } = useUser(); + const updateUser = useUpdateUser(); const router = useRouter(); const currentPath = router.asPath; + const [tree, setTree] = useState(); + const initialTree = useMemo(() => { - if (collections.length > 0) { + if ( + // !tree && + collections.length > 0 + ) { return buildTreeFromCollections( collections, router, - account.collectionOrder + tree, + user.collectionOrder ); - } - return undefined; - }, [collections, router]); - - const [tree, setTree] = useState(initialTree); + } else return undefined; + }, [collections, user, router]); useEffect(() => { + // if (!tree) setTree(initialTree); }, [initialTree]); useEffect(() => { - if (account.username) { + if (user.username) { if ( - (!account.collectionOrder || account.collectionOrder.length === 0) && + (!user.collectionOrder || user.collectionOrder.length === 0) && collections.length > 0 ) - updateAccount({ - ...account, + updateUser.mutate({ + ...user, collectionOrder: collections .filter( (e) => e.parentId === null || !collections.find((i) => i.id === e.parentId) ) // Filter out collections with non-null parentId - .map((e) => e.id as number), // Use "as number" to assert that e.id is a number + .map((e) => e.id as number), }); else { - const newCollectionOrder: number[] = [ - ...(account.collectionOrder || []), - ]; + const newCollectionOrder: number[] = [...(user.collectionOrder || [])]; // Start with collections that are in both account.collectionOrder and collections const existingCollectionIds = collections.map((c) => c.id as number); - const filteredCollectionOrder = account.collectionOrder.filter((id) => + const filteredCollectionOrder = user.collectionOrder.filter((id: any) => existingCollectionIds.includes(id) ); @@ -76,7 +85,7 @@ const CollectionListing = () => { collections.forEach((collection) => { if ( !filteredCollectionOrder.includes(collection.id as number) && - (!collection.parentId || collection.ownerId === account.id) + (!collection.parentId || collection.ownerId === user.id) ) { filteredCollectionOrder.push(collection.id as number); } @@ -85,10 +94,10 @@ const CollectionListing = () => { // check if the newCollectionOrder is the same as the old one if ( JSON.stringify(newCollectionOrder) !== - JSON.stringify(account.collectionOrder) + JSON.stringify(user.collectionOrder) ) { - updateAccount({ - ...account, + updateUser.mutateAsync({ + ...user, collectionOrder: newCollectionOrder, }); } @@ -136,30 +145,35 @@ const CollectionListing = () => { ); if ( - (movedCollection?.ownerId !== account.id && + (movedCollection?.ownerId !== user.id && destination.parentId !== source.parentId) || - (destinationCollection?.ownerId !== account.id && + (destinationCollection?.ownerId !== user.id && destination.parentId !== "root") ) { - return toast.error( - "You can't make change to a collection you don't own." - ); + return toast.error(t("cant_change_collection_you_dont_own")); } setTree((currentTree) => moveItemOnTree(currentTree!, source, destination)); - const updatedCollectionOrder = [...account.collectionOrder]; + const updatedCollectionOrder = [...user.collectionOrder]; if (source.parentId !== destination.parentId) { - await updateCollection({ - ...movedCollection, - parentId: - destination.parentId && destination.parentId !== "root" - ? Number(destination.parentId) - : destination.parentId === "root" - ? "root" - : null, - } as any); + await updateCollection.mutateAsync( + { + ...movedCollection, + parentId: + destination.parentId && destination.parentId !== "root" + ? Number(destination.parentId) + : destination.parentId === "root" + ? "root" + : null, + }, + { + onError: (error) => { + toast.error(error.message); + }, + } + ); } if ( @@ -172,8 +186,8 @@ const CollectionListing = () => { updatedCollectionOrder.splice(destination.index, 0, movedCollectionId); - await updateAccount({ - ...account, + await updateUser.mutateAsync({ + ...user, collectionOrder: updatedCollectionOrder, }); } else if ( @@ -182,8 +196,8 @@ const CollectionListing = () => { ) { updatedCollectionOrder.splice(destination.index, 0, movedCollectionId); - await updateAccount({ - ...account, + updateUser.mutate({ + ...user, collectionOrder: updatedCollectionOrder, }); } else if ( @@ -193,15 +207,27 @@ const CollectionListing = () => { ) { updatedCollectionOrder.splice(source.index, 1); - await updateAccount({ - ...account, + await updateUser.mutateAsync({ + ...user, collectionOrder: updatedCollectionOrder, }); } }; - if (!tree) { - return <>; + if (isLoading) { + return ( +
    +
    +
    +
    +
    + ); + } else if (!tree) { + return ( +

    + {t("you_have_no_collections")} +

    + ); } else return ( - {Icon(item as ExtendedTreeItem, onExpand, onCollapse)} + {Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)} - + {collection.icon ? ( + + ) : ( + + )} +

    {collection.name}

    - {collection.isPublic ? ( + {collection.isPublic && ( - ) : undefined} + )}
    {collection._count?.links}
    @@ -265,7 +302,7 @@ const renderItem = ( ); }; -const Icon = ( +const Dropdown = ( item: ExtendedTreeItem, onExpand: (id: ItemId) => void, onCollapse: (id: ItemId) => void @@ -288,6 +325,7 @@ const Icon = ( const buildTreeFromCollections = ( collections: CollectionIncludingMembersAndLinkCount[], router: ReturnType, + tree?: TreeData, order?: number[] ): TreeData => { if (order) { @@ -302,13 +340,15 @@ const buildTreeFromCollections = ( id: collection.id, children: [], hasChildren: false, - isExpanded: false, + isExpanded: tree?.items[collection.id as number]?.isExpanded || false, data: { id: collection.id, parentId: collection.parentId, name: collection.name, description: collection.description, color: collection.color, + icon: collection.icon, + iconWeight: collection.iconWeight, isPublic: collection.isPublic, ownerId: collection.ownerId, createdAt: collection.createdAt, diff --git a/components/CopyButton.tsx b/components/CopyButton.tsx new file mode 100644 index 0000000..b949475 --- /dev/null +++ b/components/CopyButton.tsx @@ -0,0 +1,32 @@ +import React, { useState } from "react"; + +type Props = { + text: string; +}; + +const CopyButton: React.FC = ({ text }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1000); + } catch (err) { + console.log(err); + } + }; + + return ( +
    + ); +}; + +export default CopyButton; diff --git a/components/DashboardItem.tsx b/components/DashboardItem.tsx index 60a5fe4..efc1303 100644 --- a/components/DashboardItem.tsx +++ b/components/DashboardItem.tsx @@ -9,12 +9,12 @@ export default function dashboardItem({ }) { return (
    -
    - +
    +

    {name}

    -

    {value}

    +

    {value || 0}

    ); diff --git a/components/Drawer.tsx b/components/Drawer.tsx new file mode 100644 index 0000000..49cd9eb --- /dev/null +++ b/components/Drawer.tsx @@ -0,0 +1,88 @@ +import React, { ReactNode, useEffect } from "react"; +import ClickAwayHandler from "@/components/ClickAwayHandler"; +import { Drawer as D } from "vaul"; + +type Props = { + toggleDrawer: Function; + children: ReactNode; + className?: string; + dismissible?: boolean; +}; + +export default function Drawer({ + toggleDrawer, + className, + children, + dismissible = true, +}: Props) { + const [drawerIsOpen, setDrawerIsOpen] = React.useState(true); + + useEffect(() => { + if (window.innerWidth >= 640) { + document.body.style.overflow = "hidden"; + document.body.style.position = "relative"; + return () => { + document.body.style.overflow = "auto"; + document.body.style.position = ""; + }; + } + }, []); + + if (window.innerWidth < 640) { + return ( + dismissible && setTimeout(() => toggleDrawer(), 350)} + dismissible={dismissible} + > + + + dismissible && setDrawerIsOpen(false)} + > + +
    +
    + {children} +
    + + + + + ); + } else { + return ( + dismissible && setTimeout(() => toggleDrawer(), 350)} + dismissible={dismissible} + direction="right" + > + + + dismissible && setDrawerIsOpen(false)} + className="z-30" + > + +
    + {children} +
    +
    +
    +
    +
    + ); + } +} diff --git a/components/Dropdown.tsx b/components/Dropdown.tsx index 4a48ab7..90371d8 100644 --- a/components/Dropdown.tsx +++ b/components/Dropdown.tsx @@ -60,47 +60,49 @@ export default function Dropdown({ } }, [points, dropdownHeight]); - return !points || pos ? ( - { - setDropdownHeight(e.height); - setDropdownWidth(e.width); - }} - style={ - points - ? { - position: "fixed", - top: `${pos?.y}px`, - left: `${pos?.x}px`, - } - : undefined - } - onClickOutside={onClickOutside} - className={`${ - className || "" - } py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`} - > - {items.map((e, i) => { - const inner = e && ( -
    -
    -

    {e.name}

    + return ( + (!points || pos) && ( + { + setDropdownHeight(e.height); + setDropdownWidth(e.width); + }} + style={ + points + ? { + position: "fixed", + top: `${pos?.y}px`, + left: `${pos?.x}px`, + } + : undefined + } + onClickOutside={onClickOutside} + className={`${ + className || "" + } py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`} + > + {items.map((e, i) => { + const inner = e && ( +
    +
    +

    {e.name}

    +
    -
    - ); + ); - return e && e.href ? ( - - {inner} - - ) : ( - e && ( -
    + return e && e.href ? ( + {inner} -
    - ) - ); - })} - - ) : null; + + ) : ( + e && ( +
    + {inner} +
    + ) + ); + })} + + ) + ); } diff --git a/components/FilterSearchDropdown.tsx b/components/FilterSearchDropdown.tsx index 01b8906..70f0aca 100644 --- a/components/FilterSearchDropdown.tsx +++ b/components/FilterSearchDropdown.tsx @@ -1,5 +1,8 @@ import { dropdownTriggerer } from "@/lib/client/utils"; -import React from "react"; +import React, { useEffect } from "react"; +import { useTranslation } from "next-i18next"; +import { resetInfiniteQueryPagination } from "@/hooks/store/links"; +import { useQueryClient } from "@tanstack/react-query"; type Props = { setSearchFilter: Function; @@ -16,6 +19,9 @@ export default function FilterSearchDropdown({ setSearchFilter, searchFilter, }: Props) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + return (
    -
      +
      • @@ -57,10 +64,11 @@ export default function FilterSearchDropdown({ className="checkbox checkbox-primary" checked={searchFilter.url} onChange={() => { + resetInfiniteQueryPagination(queryClient, ["links"]); setSearchFilter({ ...searchFilter, url: !searchFilter.url }); }} /> - Link + {t("link")}
      • @@ -75,13 +83,16 @@ export default function FilterSearchDropdown({ className="checkbox checkbox-primary" checked={searchFilter.description} onChange={() => { + resetInfiniteQueryPagination(queryClient, ["links"]); setSearchFilter({ ...searchFilter, description: !searchFilter.description, }); }} /> - Description + + {t("description")} +
      • @@ -96,13 +107,11 @@ export default function FilterSearchDropdown({ className="checkbox checkbox-primary" checked={searchFilter.tags} onChange={() => { - setSearchFilter({ - ...searchFilter, - tags: !searchFilter.tags, - }); + resetInfiniteQueryPagination(queryClient, ["links"]); + setSearchFilter({ ...searchFilter, tags: !searchFilter.tags }); }} /> - Tags + {t("tags")}
      • @@ -117,15 +126,19 @@ export default function FilterSearchDropdown({ className="checkbox checkbox-primary" checked={searchFilter.textContent} onChange={() => { + resetInfiniteQueryPagination(queryClient, ["links"]); setSearchFilter({ ...searchFilter, textContent: !searchFilter.textContent, }); }} /> - Full Content - -
        Slower
        + + {t("full_content")} + +
        + {t("slower")} +
      diff --git a/components/Icon.tsx b/components/Icon.tsx new file mode 100644 index 0000000..1830f0b --- /dev/null +++ b/components/Icon.tsx @@ -0,0 +1,18 @@ +import React, { forwardRef } from "react"; +import * as Icons from "@phosphor-icons/react"; + +type Props = { + icon: string; +} & Icons.IconProps; + +const Icon = forwardRef(({ icon, ...rest }, ref) => { + const IconComponent: any = Icons[icon as keyof typeof Icons]; + + if (!IconComponent) { + return null; + } else return ; +}); + +Icon.displayName = "Icon"; + +export default Icon; diff --git a/components/IconGrid.tsx b/components/IconGrid.tsx new file mode 100644 index 0000000..b6333a7 --- /dev/null +++ b/components/IconGrid.tsx @@ -0,0 +1,49 @@ +import { icons } from "@/lib/client/icons"; +import Fuse from "fuse.js"; +import { useMemo } from "react"; + +const fuse = new Fuse(icons, { + keys: [{ name: "name", weight: 4 }, "tags", "categories"], + threshold: 0.2, + useExtendedSearch: true, +}); + +type Props = { + query: string; + color: string; + weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin"; + iconName?: string; + setIconName: Function; +}; + +const IconGrid = ({ query, color, weight, iconName, setIconName }: Props) => { + const filteredQueryResultsSelector = useMemo(() => { + if (!query) { + return icons; + } + return fuse.search(query).map((result) => result.item); + }, [query]); + + return ( + <> + {filteredQueryResultsSelector.map((icon) => { + const IconComponent = icon.Icon; + return ( +
      setIconName(icon.pascal_name)} + className={`cursor-pointer btn p-1 box-border bg-base-100 border-none w-full ${ + icon.pascal_name === iconName + ? "outline outline-1 outline-primary" + : "" + }`} + > + +
      + ); + })} + + ); +}; + +export default IconGrid; diff --git a/components/IconPicker.tsx b/components/IconPicker.tsx new file mode 100644 index 0000000..82643a8 --- /dev/null +++ b/components/IconPicker.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; +import TextInput from "./TextInput"; +import Popover from "./Popover"; +import { HexColorPicker } from "react-colorful"; +import { useTranslation } from "next-i18next"; +import Icon from "./Icon"; +import { IconWeight } from "@phosphor-icons/react"; +import IconGrid from "./IconGrid"; +import IconPopover from "./IconPopover"; +import clsx from "clsx"; + +type Props = { + alignment?: string; + color: string; + setColor: Function; + iconName?: string; + setIconName: Function; + weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin"; + setWeight: Function; + hideDefaultIcon?: boolean; + reset: Function; + className?: string; +}; + +const IconPicker = ({ + alignment, + color, + setColor, + iconName, + setIconName, + weight, + setWeight, + hideDefaultIcon, + className, + reset, +}: Props) => { + const { t } = useTranslation(); + const [iconPicker, setIconPicker] = useState(false); + + return ( +
      +
      setIconPicker(!iconPicker)} + className="btn btn-square w-20 h-20" + > + {iconName ? ( + + ) : !iconName && hideDefaultIcon ? ( +

      {t("set_custom_icon")}

      + ) : ( + + )} +
      + {iconPicker && ( + setIconPicker(false)} + className={clsx( + className, + alignment || "lg:-translate-x-1/3 top-20 left-0" + )} + /> + )} +
      + ); +}; + +export default IconPicker; diff --git a/components/IconPopover.tsx b/components/IconPopover.tsx new file mode 100644 index 0000000..cdbe8f6 --- /dev/null +++ b/components/IconPopover.tsx @@ -0,0 +1,142 @@ +import React, { useState } from "react"; +import TextInput from "./TextInput"; +import Popover from "./Popover"; +import { HexColorPicker } from "react-colorful"; +import { useTranslation } from "next-i18next"; +import IconGrid from "./IconGrid"; +import clsx from "clsx"; + +type Props = { + alignment?: string; + color: string; + setColor: Function; + iconName?: string; + setIconName: Function; + weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin"; + setWeight: Function; + reset: Function; + className?: string; + onClose: Function; +}; + +const IconPopover = ({ + alignment, + color, + setColor, + iconName, + setIconName, + weight, + setWeight, + reset, + className, + onClose, +}: Props) => { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + + return ( + onClose()} + className={clsx( + className, + "fade-in bg-base-200 border border-neutral-content p-2 w-[22.5rem] rounded-lg shadow-md" + )} + > +
      +
      + setQuery(e.target.value)} + /> + +
      + +
      + +
      + setColor(e)} /> + +
      + + + + + + +
      +
      +
      } + > + {t("reset_defaults")} +
      +
      +
      +
      + ); +}; + +export default IconPopover; diff --git a/components/InputSelect/CollectionSelection.tsx b/components/InputSelect/CollectionSelection.tsx index 99b999e..f53df37 100644 --- a/components/InputSelect/CollectionSelection.tsx +++ b/components/InputSelect/CollectionSelection.tsx @@ -1,10 +1,10 @@ -import useCollectionStore from "@/store/collections"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { styles } from "./styles"; import { Options } from "./types"; import CreatableSelect from "react-select/creatable"; import Select from "react-select"; +import { useCollections } from "@/hooks/store/collections"; type Props = { onChange: any; @@ -16,6 +16,8 @@ type Props = { } | undefined; creatable?: boolean; + autoFocus?: boolean; + onBlur?: any; }; export default function CollectionSelection({ @@ -23,8 +25,11 @@ export default function CollectionSelection({ defaultValue, showDefaultValue = true, creatable = true, + autoFocus, + onBlur, }: Props) { - const { collections } = useCollectionStore(); + const { data: collections = [] } = useCollections(); + const router = useRouter(); const [options, setOptions] = useState([]); @@ -75,7 +80,7 @@ export default function CollectionSelection({ return (
      {data.label} @@ -103,6 +108,8 @@ export default function CollectionSelection({ onChange={onChange} options={options} styles={styles} + autoFocus={autoFocus} + onBlur={onBlur} defaultValue={showDefaultValue ? defaultValue : null} components={{ Option: customOption, @@ -119,7 +126,9 @@ export default function CollectionSelection({ onChange={onChange} options={options} styles={styles} + autoFocus={autoFocus} defaultValue={showDefaultValue ? defaultValue : null} + onBlur={onBlur} components={{ Option: customOption, }} diff --git a/components/InputSelect/TagSelection.tsx b/components/InputSelect/TagSelection.tsx index d901b40..cf03e6f 100644 --- a/components/InputSelect/TagSelection.tsx +++ b/components/InputSelect/TagSelection.tsx @@ -1,24 +1,31 @@ -import useTagStore from "@/store/tags"; import { useEffect, useState } from "react"; import CreatableSelect from "react-select/creatable"; import { styles } from "./styles"; import { Options } from "./types"; +import { useTags } from "@/hooks/store/tags"; type Props = { onChange: any; defaultValue?: { - value: number; + value?: number; label: string; }[]; + autoFocus?: boolean; + onBlur?: any; }; -export default function TagSelection({ onChange, defaultValue }: Props) { - const { tags } = useTagStore(); +export default function TagSelection({ + onChange, + defaultValue, + autoFocus, + onBlur, +}: Props) { + const { data: tags = [] } = useTags(); const [options, setOptions] = useState([]); useEffect(() => { - const formatedCollections = tags.map((e) => { + const formatedCollections = tags.map((e: any) => { return { value: e.id, label: e.name }; }); @@ -34,8 +41,9 @@ export default function TagSelection({ onChange, defaultValue }: Props) { options={options} styles={styles} defaultValue={defaultValue} - // menuPosition="fixed" isMulti + autoFocus={autoFocus} + onBlur={onBlur} /> ); } diff --git a/components/InputSelect/styles.ts b/components/InputSelect/styles.ts index 96aad6d..f05f4a5 100644 --- a/components/InputSelect/styles.ts +++ b/components/InputSelect/styles.ts @@ -14,7 +14,7 @@ export const styles: StylesConfig = { ? "oklch(var(--p))" : "oklch(var(--nc))", }, - transition: "all 50ms", + transition: "all 100ms", }), control: (styles, state) => ({ ...styles, @@ -50,19 +50,28 @@ export const styles: StylesConfig = { multiValue: (styles) => { return { ...styles, - backgroundColor: "#0ea5e9", - color: "white", + backgroundColor: "oklch(var(--b2))", + color: "oklch(var(--bc))", + display: "flex", + alignItems: "center", + gap: "0.1rem", + marginRight: "0.4rem", }; }, multiValueLabel: (styles) => ({ ...styles, - color: "white", + color: "oklch(var(--bc))", }), multiValueRemove: (styles) => ({ ...styles, + height: "1.2rem", + width: "1.2rem", + borderRadius: "100px", + transition: "all 100ms", + color: "oklch(var(--w))", ":hover": { - color: "white", - backgroundColor: "#38bdf8", + color: "red", + backgroundColor: "oklch(var(--nc))", }, }), menuPortal: (base) => ({ ...base, zIndex: 9999 }), diff --git a/components/InstallApp.tsx b/components/InstallApp.tsx new file mode 100644 index 0000000..50071da --- /dev/null +++ b/components/InstallApp.tsx @@ -0,0 +1,54 @@ +import { isPWA } from "@/lib/client/utils"; +import React, { useState } from "react"; +import { Trans } from "next-i18next"; + +type Props = {}; + +const InstallApp = (props: Props) => { + const [isOpen, setIsOpen] = useState(true); + + return isOpen && !isPWA() ? ( +
      +
      + + + + + +

      + , + ]} + /> +

      + +
      +
      + ) : ( + <> + ); +}; + +export default InstallApp; diff --git a/components/LinkDetails.tsx b/components/LinkDetails.tsx new file mode 100644 index 0000000..e745055 --- /dev/null +++ b/components/LinkDetails.tsx @@ -0,0 +1,663 @@ +import React, { useEffect, useState } from "react"; +import { + LinkIncludingShortenedCollectionAndTags, + ArchivedFormat, +} from "@/types/global"; +import Link from "next/link"; +import { + pdfAvailable, + readabilityAvailable, + monolithAvailable, + screenshotAvailable, + previewAvailable, +} from "@/lib/shared/getArchiveValidity"; +import PreservedFormatRow from "@/components/PreserverdFormatRow"; +import getPublicUserData from "@/lib/client/getPublicUserData"; +import { useTranslation } from "next-i18next"; +import { BeatLoader } from "react-spinners"; +import { useUser } from "@/hooks/store/user"; +import { + useGetLink, + useUpdateLink, + useUpdatePreview, +} from "@/hooks/store/links"; +import LinkIcon from "./LinkViews/LinkComponents/LinkIcon"; +import CopyButton from "./CopyButton"; +import { useRouter } from "next/router"; +import Icon from "./Icon"; +import { IconWeight } from "@phosphor-icons/react"; +import Image from "next/image"; +import clsx from "clsx"; +import toast from "react-hot-toast"; +import CollectionSelection from "./InputSelect/CollectionSelection"; +import TagSelection from "./InputSelect/TagSelection"; +import unescapeString from "@/lib/client/unescapeString"; +import IconPopover from "./IconPopover"; +import TextInput from "./TextInput"; +import usePermissions from "@/hooks/usePermissions"; + +type Props = { + className?: string; + activeLink: LinkIncludingShortenedCollectionAndTags; + standalone?: boolean; + mode?: "view" | "edit"; + setMode?: Function; +}; + +export default function LinkDetails({ + className, + activeLink, + standalone, + mode = "view", + setMode, +}: Props) { + const [link, setLink] = + useState(activeLink); + + useEffect(() => { + setLink(activeLink); + }, [activeLink]); + + const permissions = usePermissions(link.collection.id as number); + + const { t } = useTranslation(); + const getLink = useGetLink(); + const { data: user = {} } = useUser(); + + const [collectionOwner, setCollectionOwner] = useState({ + id: null as unknown as number, + name: "", + username: "", + image: "", + archiveAsScreenshot: undefined as unknown as boolean, + archiveAsMonolith: undefined as unknown as boolean, + archiveAsPDF: undefined as unknown as boolean, + }); + + useEffect(() => { + const fetchOwner = async () => { + if (link.collection.ownerId !== user.id) { + const owner = await getPublicUserData( + link.collection.ownerId as number + ); + setCollectionOwner(owner); + } else if (link.collection.ownerId === user.id) { + setCollectionOwner({ + id: user.id as number, + name: user.name, + username: user.username as string, + image: user.image as string, + archiveAsScreenshot: user.archiveAsScreenshot as boolean, + archiveAsMonolith: user.archiveAsScreenshot as boolean, + archiveAsPDF: user.archiveAsPDF as boolean, + }); + } + }; + + fetchOwner(); + }, [link.collection.ownerId]); + + const isReady = () => { + return ( + link && + (collectionOwner.archiveAsScreenshot === true + ? link.pdf && link.pdf !== "pending" + : true) && + (collectionOwner.archiveAsMonolith === true + ? link.monolith && link.monolith !== "pending" + : true) && + (collectionOwner.archiveAsPDF === true + ? link.pdf && link.pdf !== "pending" + : true) && + link.readable && + link.readable !== "pending" + ); + }; + + const atLeastOneFormatAvailable = () => { + return ( + screenshotAvailable(link) || + pdfAvailable(link) || + readabilityAvailable(link) || + monolithAvailable(link) + ); + }; + + useEffect(() => { + (async () => { + await getLink.mutateAsync({ + id: link.id as number, + }); + })(); + + let interval: any; + + if (!isReady()) { + interval = setInterval(async () => { + await getLink.mutateAsync({ + id: link.id as number, + }); + }, 5000); + } else { + if (interval) { + clearInterval(interval); + } + } + + return () => { + if (interval) { + clearInterval(interval); + } + }; + }, [link.monolith]); + + const router = useRouter(); + + const isPublicRoute = router.pathname.startsWith("/public") ? true : false; + + const updateLink = useUpdateLink(); + const updatePreview = useUpdatePreview(); + + const submit = async (e?: any) => { + e?.preventDefault(); + + const { updatedAt: b, ...oldLink } = activeLink; + const { updatedAt: a, ...newLink } = link; + + if (JSON.stringify(oldLink) === JSON.stringify(newLink)) { + return; + } + + const load = toast.loading(t("updating")); + + await updateLink.mutateAsync(link, { + onSettled: (data, error) => { + toast.dismiss(load); + + if (error) { + toast.error(error.message); + } else { + toast.success(t("updated")); + setMode && setMode("view"); + setLink(data); + } + }, + }); + }; + + const setCollection = (e: any) => { + if (e?.__isNew__) e.value = null; + setLink({ + ...link, + collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId }, + }); + }; + + const setTags = (e: any) => { + const tagNames = e.map((e: any) => ({ name: e.label })); + setLink({ ...link, tags: tagNames }); + }; + + const [iconPopover, setIconPopover] = useState(false); + + return ( +
      +
      +
      + {previewAvailable(link) ? ( + { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + ) : link.preview === "unavailable" ? ( +
      + ) : ( +
      + )} + + {!standalone && (permissions === true || permissions?.canUpdate) && ( +
      + +
      + )} +
      + + {!standalone && (permissions === true || permissions?.canUpdate) ? ( +
      +
      + setIconPopover(true)} + /> +
      + {iconPopover && ( + setLink({ ...link, color })} + weight={(link.iconWeight || "regular") as IconWeight} + setWeight={(iconWeight: string) => + setLink({ ...link, iconWeight }) + } + iconName={link.icon as string} + setIconName={(icon: string) => setLink({ ...link, icon })} + reset={() => + setLink({ + ...link, + color: "", + icon: "", + iconWeight: "", + }) + } + className="top-12" + onClose={() => { + setIconPopover(false); + submit(); + }} + /> + )} +
      + ) : ( +
      + setIconPopover(true)} /> +
      + )} + +
      + {mode === "view" && ( +
      +

      + {link.name || t("untitled")} +

      +
      + )} + + {mode === "edit" && ( + <> +
      + +
      +

      + {t("name")} +

      + setLink({ ...link, name: e.target.value })} + placeholder={t("placeholder_example_link")} + className="bg-base-200" + /> +
      + + )} + + {link.url && mode === "view" ? ( + <> +
      + +

      {t("link")}

      + +
      +
      + + {link.url} + +
      + +
      +
      +
      + + ) : activeLink.url ? ( + <> +
      + +
      +

      + {t("link")} +

      + setLink({ ...link, url: e.target.value })} + placeholder={t("placeholder_example_link")} + className="bg-base-200" + /> +
      + + ) : undefined} + +
      + +
      +

      + {t("collection")} +

      + + {mode === "view" ? ( +
      + +

      {link.collection.name}

      +
      + {link.collection.icon ? ( + + ) : ( + + )} +
      + +
      + ) : ( + + )} +
      + +
      + +
      +

      + {t("tags")} +

      + + {mode === "view" ? ( +
      + {link.tags && link.tags[0] ? ( + link.tags.map((tag) => + isPublicRoute ? ( +
      + {tag.name} +
      + ) : ( + + {tag.name} + + ) + ) + ) : ( +
      {t("no_tags")}
      + )} +
      + ) : ( + ({ + label: e.name, + value: e.id, + }))} + /> + )} +
      + +
      + +
      +

      + {t("description")} +

      + + {mode === "view" ? ( +
      + {link.description ? ( +

      {link.description}

      + ) : ( +

      {t("no_description_provided")}

      + )} +
      + ) : ( +