diff --git a/.env.sample b/.env.sample index 98ce893..a7acfa2 100644 --- a/.env.sample +++ b/.env.sample @@ -21,6 +21,8 @@ ARCHIVE_TAKE_COUNT= BROWSER_TIMEOUT= IGNORE_UNAUTHORIZED_CA= IGNORE_HTTPS_ERRORS= +IGNORE_URL_SIZE_LIMIT= +ADMINISTRATOR= # AWS S3 Settings SPACES_KEY= @@ -34,6 +36,7 @@ SPACES_FORCE_PATH_STYLE= NEXT_PUBLIC_EMAIL_PROVIDER= EMAIL_FROM= EMAIL_SERVER= +BASE_URL= # Proxy settings PROXY= @@ -79,6 +82,13 @@ 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= diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml new file mode 100644 index 0000000..2d226af --- /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@v2 + + - name: Use Node.js + uses: actions/setup-node@v3 + 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@v3 + if: always() + with: + name: playwright-report + path: test-results + retention-days: 30 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..9de1541 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,4 +20,4 @@ COPY . . RUN yarn prisma generate && \ yarn build -CMD yarn prisma migrate deploy && yarn start +CMD yarn prisma migrate deploy && yarn start \ No newline at end of file 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..2f97297 --- /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 9f09a77..086f742 100644 --- a/components/CollectionCard.tsx +++ b/components/CollectionCard.tsx @@ -10,6 +10,7 @@ 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"; type Props = { collection: CollectionIncludingMembersAndLinkCount; @@ -17,6 +18,7 @@ type Props = { }; export default function CollectionCard({ collection, className }: Props) { + const { t } = useTranslation(); const { settings } = useLocalSettingsStore(); const { account } = useAccountStore(); @@ -78,8 +80,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")}
    • @@ -114,7 +118,9 @@ export default function CollectionCard({ collection, className }: Props) { setDeleteCollectionModal(true); }} > - {permissions === true ? "Delete Collection" : "Leave Collection"} + {permissions === true + ? t("delete_collection") + : t("leave_collection")}
    diff --git a/components/CollectionListing.tsx b/components/CollectionListing.tsx index 171117b..fbe2f7b 100644 --- a/components/CollectionListing.tsx +++ b/components/CollectionListing.tsx @@ -16,12 +16,14 @@ 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"; interface ExtendedTreeItem extends TreeItem { data: Collection; } const CollectionListing = () => { + const { t } = useTranslation(); const { collections, updateCollection } = useCollectionStore(); const { account, updateAccount } = useAccountStore(); @@ -141,9 +143,7 @@ const CollectionListing = () => { (destinationCollection?.ownerId !== account.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)); @@ -201,7 +201,11 @@ const CollectionListing = () => { }; if (!tree) { - return <>; + return ( +

    + {t("you_have_no_collections")} +

    + ); } else return ( -
    - +
    +

    {name}

    -

    {value}

    +

    {value}

    ); diff --git a/components/FilterSearchDropdown.tsx b/components/FilterSearchDropdown.tsx index 01b8906..68bb55b 100644 --- a/components/FilterSearchDropdown.tsx +++ b/components/FilterSearchDropdown.tsx @@ -1,5 +1,6 @@ import { dropdownTriggerer } from "@/lib/client/utils"; import React from "react"; +import { useTranslation } from "next-i18next"; type Props = { setSearchFilter: Function; @@ -16,6 +17,8 @@ export default function FilterSearchDropdown({ setSearchFilter, searchFilter, }: Props) { + const { t } = useTranslation(); + return (
    { - setSearchFilter({ ...searchFilter, name: !searchFilter.name }); - }} + onChange={() => + setSearchFilter({ ...searchFilter, name: !searchFilter.name }) + } /> - Name + {t("name")}
  • @@ -56,11 +59,11 @@ export default function FilterSearchDropdown({ name="search-filter-checkbox" className="checkbox checkbox-primary" checked={searchFilter.url} - onChange={() => { - setSearchFilter({ ...searchFilter, url: !searchFilter.url }); - }} + onChange={() => + setSearchFilter({ ...searchFilter, url: !searchFilter.url }) + } /> - Link + {t("link")}
  • @@ -74,14 +77,14 @@ export default function FilterSearchDropdown({ name="search-filter-checkbox" className="checkbox checkbox-primary" checked={searchFilter.description} - onChange={() => { + onChange={() => setSearchFilter({ ...searchFilter, description: !searchFilter.description, - }); - }} + }) + } /> - Description + {t("description")}
  • @@ -95,14 +98,11 @@ export default function FilterSearchDropdown({ name="search-filter-checkbox" className="checkbox checkbox-primary" checked={searchFilter.tags} - onChange={() => { - setSearchFilter({ - ...searchFilter, - tags: !searchFilter.tags, - }); - }} + onChange={() => + setSearchFilter({ ...searchFilter, tags: !searchFilter.tags }) + } /> - Tags + {t("tags")}
  • @@ -116,16 +116,17 @@ export default function FilterSearchDropdown({ name="search-filter-checkbox" className="checkbox checkbox-primary" checked={searchFilter.textContent} - onChange={() => { + onChange={() => setSearchFilter({ ...searchFilter, textContent: !searchFilter.textContent, - }); - }} + }) + } /> - Full Content - -
    Slower
    + {t("full_content")} +
    + {t("slower")} +
diff --git a/components/InstallApp.tsx b/components/InstallApp.tsx new file mode 100644 index 0000000..4b70309 --- /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/LinkListOptions.tsx b/components/LinkListOptions.tsx new file mode 100644 index 0000000..880e91d --- /dev/null +++ b/components/LinkListOptions.tsx @@ -0,0 +1,203 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; +import FilterSearchDropdown from "./FilterSearchDropdown"; +import SortDropdown from "./SortDropdown"; +import ViewDropdown from "./ViewDropdown"; +import { TFunction } from "i18next"; +import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal"; +import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal"; +import toast from "react-hot-toast"; +import useCollectivePermissions from "@/hooks/useCollectivePermissions"; +import { useRouter } from "next/router"; +import useLinkStore from "@/store/links"; +import { Sort } from "@/types/global"; + +type Props = { + children: React.ReactNode; + t: TFunction<"translation", undefined>; + viewMode: string; + setViewMode: Dispatch>; + searchFilter?: { + name: boolean; + url: boolean; + description: boolean; + tags: boolean; + textContent: boolean; + }; + setSearchFilter?: (filter: { + name: boolean; + url: boolean; + description: boolean; + tags: boolean; + textContent: boolean; + }) => void; + sortBy: Sort; + setSortBy: Dispatch>; + editMode?: boolean; + setEditMode?: (mode: boolean) => void; +}; + +const LinkListOptions = ({ + children, + t, + viewMode, + setViewMode, + searchFilter, + setSearchFilter, + sortBy, + setSortBy, + editMode, + setEditMode, +}: Props) => { + const { links, selectedLinks, setSelectedLinks, deleteLinksById } = + useLinkStore(); + + const router = useRouter(); + + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + + useEffect(() => { + if (editMode && setEditMode) return setEditMode(false); + }, [router]); + + const collectivePermissions = useCollectivePermissions( + selectedLinks.map((link) => link.collectionId as number) + ); + + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading(t("deleting_selections")); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + selectedLinks.length === 1 + ? t("link_deleted") + : t("links_deleted", { count: selectedLinks.length }) + ); + }; + + return ( + <> +
+ {children} + +
+
+ {links.length > 0 && editMode !== undefined && setEditMode && ( +
{ + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
+ )} + {searchFilter && setSearchFilter && ( + + )} + + +
+
+
+ + {editMode && links.length > 0 && ( +
+ {links.length > 0 && ( +
+ handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length === 1 + ? t("link_selected") + : t("links_selected", { count: selectedLinks.length })} + + ) : ( + {t("nothing_selected")} + )} +
+ )} +
+ + +
+
+ )} + + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} + + ); +}; + +export default LinkListOptions; diff --git a/components/LinkViews/Layouts/CardView.tsx b/components/LinkViews/Layouts/CardView.tsx index 892e3ff..45ea8f8 100644 --- a/components/LinkViews/Layouts/CardView.tsx +++ b/components/LinkViews/Layouts/CardView.tsx @@ -13,7 +13,7 @@ export default function CardView({ isLoading?: boolean; }) { return ( -
+
{links.map((e, i) => { return ( - {links.map((e, i) => { - return ; - })} -
- ); -} diff --git a/components/LinkViews/Layouts/MasonryView.tsx b/components/LinkViews/Layouts/MasonryView.tsx new file mode 100644 index 0000000..bceaf33 --- /dev/null +++ b/components/LinkViews/Layouts/MasonryView.tsx @@ -0,0 +1,58 @@ +import LinkMasonry from "@/components/LinkViews/LinkMasonry"; +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import { GridLoader } from "react-spinners"; +import Masonry from "react-masonry-css"; +import resolveConfig from "tailwindcss/resolveConfig"; +import tailwindConfig from "../../../tailwind.config.js"; +import { useMemo } from "react"; + +export default function MasonryView({ + links, + editMode, + isLoading, +}: { + links: LinkIncludingShortenedCollectionAndTags[]; + editMode?: boolean; + isLoading?: boolean; +}) { + const fullConfig = resolveConfig(tailwindConfig as any); + + const breakpointColumnsObj = useMemo(() => { + return { + default: 5, + 1900: 4, + 1500: 3, + 880: 2, + 550: 1, + }; + }, []); + + return ( + + {links.map((e, i) => { + return ( + + ); + })} + + {isLoading && links.length > 0 && ( + + )} + + ); +} diff --git a/components/LinkViews/LinkCard.tsx b/components/LinkViews/LinkCard.tsx index d93904a..70f553c 100644 --- a/components/LinkViews/LinkCard.tsx +++ b/components/LinkViews/LinkCard.tsx @@ -19,6 +19,8 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref"; import useAccountStore from "@/store/account"; import usePermissions from "@/hooks/usePermissions"; import toast from "react-hot-toast"; +import LinkTypeBadge from "./LinkComponents/LinkTypeBadge"; +import { useTranslation } from "next-i18next"; type Props = { link: LinkIncludingShortenedCollectionAndTags; @@ -29,6 +31,9 @@ type Props = { }; export default function LinkCard({ link, flipDropdown, editMode }: Props) { + const { t } = useTranslation(); + + const viewMode = localStorage.getItem("viewMode") || "card"; const { collections } = useCollectionStore(); const { account } = useAccountStore(); @@ -53,7 +58,9 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { let shortendURL; try { - shortendURL = new URL(link.url || "").host.toLowerCase(); + if (link.url) { + shortendURL = new URL(link.url).host.toLowerCase(); + } } catch (error) { console.log(error); } @@ -109,7 +116,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { editMode && (permissions === true || permissions?.canCreate || permissions?.canDelete); - // window.open ('www.yourdomain.com', '_ blank'); return (
!editMode && window.open(generateLinkHref(link, account), "_blank") } > -
- {previewAvailable(link) ? ( - { - const target = e.target as HTMLElement; - target.style.display = "none"; - }} - /> - ) : link.preview === "unavailable" ? ( -
- ) : ( -
- )} -
- -
-
- -
- -
-

- {unescapeString(link.name || link.description) || link.url} -

- - { - e.stopPropagation(); - }} - className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100" - > - -

{shortendURL}

- -
- -
- -
-
- {collection && ( - +
+
+ {previewAvailable(link) ? ( + { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + ) : link.preview === "unavailable" ? ( +
+ ) : ( +
+ )} + {link.type !== "image" && ( +
+ +
)}
- +
+
+ +
+
+

+ {unescapeString(link.name)} +

+ + +
+ +
+
+ +
+
+ {collection && ( + + )} +
+ +
+
{showInfo && ( -
+
setShowInfo(!showInfo)} className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10" >
-

Description

+

+ {t("description")} +

-
+

{link.description ? ( unescapeString(link.description) ) : ( - No description provided. + {t("no_description")} )}

- {link.tags[0] && ( + {link.tags && link.tags[0] && ( <> -

Tags

+

+ {t("tags")} +

-
+
diff --git a/components/LinkViews/LinkComponents/LinkActions.tsx b/components/LinkViews/LinkComponents/LinkActions.tsx index d809dfc..46aff1d 100644 --- a/components/LinkViews/LinkComponents/LinkActions.tsx +++ b/components/LinkViews/LinkComponents/LinkActions.tsx @@ -11,6 +11,7 @@ import useLinkStore from "@/store/links"; import { toast } from "react-hot-toast"; import useAccountStore from "@/store/account"; import { dropdownTriggerer } from "@/lib/client/utils"; +import { useTranslation } from "next-i18next"; type Props = { link: LinkIncludingShortenedCollectionAndTags; @@ -18,6 +19,7 @@ type Props = { position?: string; toggleShowInfo?: () => void; linkInfo?: boolean; + alignToTop?: boolean; flipDropdown?: boolean; }; @@ -26,8 +28,11 @@ export default function LinkActions({ toggleShowInfo, position, linkInfo, + alignToTop, flipDropdown, }: Props) { + const { t } = useTranslation(); + const permissions = usePermissions(link.collection.id as number); const [editLinkModal, setEditLinkModal] = useState(false); @@ -41,7 +46,7 @@ export default function LinkActions({ const pinLink = async () => { const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0]; - const load = toast.loading("Applying..."); + const load = toast.loading(t("applying")); const response = await updateLink({ ...link, @@ -51,25 +56,25 @@ export default function LinkActions({ toast.dismiss(load); response.ok && - toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`); + toast.success(isAlreadyPinned ? t("link_unpinned") : t("link_unpinned")); }; const deleteLink = async () => { - const load = toast.loading("Deleting..."); + const load = toast.loading(t("deleting")); const response = await removeLink(link.id as number); toast.dismiss(load); - response.ok && toast.success(`Link Deleted.`); + response.ok && toast.success(t("deleted")); }; return ( <>
-
    +
    • {link?.pinnedBy && link.pinnedBy[0] - ? "Unpin" - : "Pin to Dashboard"} + ? t("unpin") + : t("pin_to_dashboard")}
    • {linkInfo !== undefined && toggleShowInfo ? ( @@ -104,7 +113,7 @@ export default function LinkActions({ toggleShowInfo(); }} > - {!linkInfo ? "Show" : "Hide"} Link Details + {!linkInfo ? t("show_link_details") : t("hide_link_details")}
) : undefined} @@ -118,22 +127,24 @@ export default function LinkActions({ setEditLinkModal(true); }} > - Edit Link + {t("edit_link")}
) : undefined} -
  • -
    { - (document?.activeElement as HTMLElement)?.blur(); - setPreservedFormatsModal(true); - }} - > - Preserved Formats -
    -
  • + {link.type === "url" && ( +
  • +
    { + (document?.activeElement as HTMLElement)?.blur(); + setPreservedFormatsModal(true); + }} + > + {t("preserved_formats")} +
    +
  • + )} {permissions === true || permissions?.canDelete ? (
  • - Delete + {t("delete")}
  • ) : undefined} diff --git a/components/LinkViews/LinkComponents/LinkDate.tsx b/components/LinkViews/LinkComponents/LinkDate.tsx index e512dcb..cdd6838 100644 --- a/components/LinkViews/LinkComponents/LinkDate.tsx +++ b/components/LinkViews/LinkComponents/LinkDate.tsx @@ -6,17 +6,16 @@ export default function LinkDate({ }: { link: LinkIncludingShortenedCollectionAndTags; }) { - const formattedDate = new Date(link.createdAt as string).toLocaleString( - "en-US", - { - year: "numeric", - month: "short", - day: "numeric", - } - ); + const formattedDate = new Date( + (link.importDate || link.createdAt) as string + ).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); return ( -
    +

    {formattedDate}

    diff --git a/components/LinkViews/LinkComponents/LinkGroupedIconURL.tsx b/components/LinkViews/LinkComponents/LinkGroupedIconURL.tsx deleted file mode 100644 index 4921c3d..0000000 --- a/components/LinkViews/LinkComponents/LinkGroupedIconURL.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; -import Image from "next/image"; -import isValidUrl from "@/lib/shared/isValidUrl"; -import React from "react"; -import Link from "next/link"; - -export default function LinkGroupedIconURL({ - link, -}: { - link: LinkIncludingShortenedCollectionAndTags; -}) { - const url = - isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined; - - const [showFavicon, setShowFavicon] = React.useState(true); - - let shortendURL; - - try { - shortendURL = new URL(link.url || "").host.toLowerCase(); - } catch (error) { - console.log(error); - } - - return ( - -
    - {link.url && url && showFavicon ? ( - { - setShowFavicon(false); - }} - /> - ) : showFavicon === false ? ( - - ) : link.type === "pdf" ? ( - - ) : link.type === "image" ? ( - - ) : link.type === "singlefile" ? ( - - ) : undefined} -

    -

    {shortendURL}

    -

    -
    - - ); -} diff --git a/components/LinkViews/LinkComponents/LinkIcon.tsx b/components/LinkViews/LinkComponents/LinkIcon.tsx index bbf6146..e38e8d1 100644 --- a/components/LinkViews/LinkComponents/LinkIcon.tsx +++ b/components/LinkViews/LinkComponents/LinkIcon.tsx @@ -5,46 +5,94 @@ import React from "react"; export default function LinkIcon({ link, - width, + className, + size, }: { link: LinkIncludingShortenedCollectionAndTags; - width?: string; + className?: string; + size?: "small" | "medium"; }) { + let iconClasses: string = + "bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10 " + + (className || ""); + + let dimension; + + switch (size) { + case "small": + dimension = " w-8 h-8"; + break; + case "medium": + dimension = " w-12 h-12"; + break; + default: + size = "medium"; + dimension = " w-12 h-12"; + break; + } + const url = isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined; - const iconClasses: string = - "bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" + - " " + - (width || "w-12"); - const [showFavicon, setShowFavicon] = React.useState(true); return ( <> - {link.url && url && showFavicon ? ( - { - setShowFavicon(false); - }} - /> - ) : showFavicon === false ? ( -
    - -
    + {link.type === "url" && url ? ( + showFavicon ? ( + { + setShowFavicon(false); + }} + /> + ) : ( + + ) ) : link.type === "pdf" ? ( - + ) : link.type === "image" ? ( - + ) : link.type === "singlefile" ? ( ) : undefined} ); } + +const LinkPlaceholderIcon = ({ + iconClasses, + size, + icon, +}: { + iconClasses: string; + size?: "small" | "medium"; + icon: string; +}) => { + return ( +
    + +
    + ); +}; diff --git a/components/LinkViews/LinkComponents/LinkTypeBadge.tsx b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx new file mode 100644 index 0000000..5a47c68 --- /dev/null +++ b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx @@ -0,0 +1,36 @@ +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import Link from "next/link"; +import React from "react"; + +export default function LinkTypeBadge({ + link, +}: { + link: LinkIncludingShortenedCollectionAndTags; +}) { + let shortendURL; + + if (link.type === "url" && link.url) { + try { + shortendURL = new URL(link.url).host.toLowerCase(); + } catch (error) { + console.log(error); + } + } + + return link.url && shortendURL ? ( + { + e.stopPropagation(); + }} + className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100 max-w-full w-fit" + > + +

    {shortendURL}

    + + ) : ( +
    {link.type}
    + ); +} diff --git a/components/LinkViews/LinkGrid.tsx b/components/LinkViews/LinkGrid.tsx deleted file mode 100644 index be4d6ed..0000000 --- a/components/LinkViews/LinkGrid.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { - CollectionIncludingMembersAndLinkCount, - LinkIncludingShortenedCollectionAndTags, -} from "@/types/global"; -import { useEffect, useState } from "react"; -import useLinkStore from "@/store/links"; -import useCollectionStore from "@/store/collections"; -import unescapeString from "@/lib/client/unescapeString"; -import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; -import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; -import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; -import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon"; -import Link from "next/link"; - -type Props = { - link: LinkIncludingShortenedCollectionAndTags; - count: number; - className?: string; -}; - -export default function LinkGrid({ link }: Props) { - const { collections } = useCollectionStore(); - - const { links } = useLinkStore(); - - let shortendURL; - - try { - shortendURL = new URL(link.url || "").host.toLowerCase(); - } catch (error) { - console.log(error); - } - - const [collection, setCollection] = - useState( - collections.find( - (e) => e.id === link.collection.id - ) as CollectionIncludingMembersAndLinkCount - ); - - useEffect(() => { - setCollection( - collections.find( - (e) => e.id === link.collection.id - ) as CollectionIncludingMembersAndLinkCount - ); - }, [collections, links]); - - return ( -
    -
    link.url && window.open(link.url || "", "_blank")} - className="cursor-pointer" - > - -

    - {unescapeString(link.name || link.description) || link.url} -

    - -
    -
    - - · - {link.url ? ( -
    { - e.preventDefault(); - window.open(link.url || "", "_blank"); - }} - className="flex items-center hover:opacity-60 cursor-pointer duration-100" - > -

    {shortendURL}

    -
    - ) : ( -
    - {link.type} -
    - )} -
    - -
    -

    {unescapeString(link.description)}

    - {link.tags[0] ? ( -
    -
    - {link.tags.map((e, i) => ( - { - e.stopPropagation(); - }} - className="btn btn-xs btn-ghost truncate max-w-[19rem]" - > - #{e.name} - - ))} -
    -
    - ) : undefined} -
    - - {}} - linkInfo={false} - link={link} - collection={collection} - /> -
    - ); -} diff --git a/components/LinkViews/LinkList.tsx b/components/LinkViews/LinkList.tsx index a535adf..0f944d8 100644 --- a/components/LinkViews/LinkList.tsx +++ b/components/LinkViews/LinkList.tsx @@ -10,12 +10,13 @@ import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon"; -import Link from "next/link"; import { isPWA } from "@/lib/client/utils"; import { generateLinkHref } from "@/lib/client/generateLinkHref"; import useAccountStore from "@/store/account"; import usePermissions from "@/hooks/usePermissions"; import toast from "react-hot-toast"; +import LinkTypeBadge from "./LinkComponents/LinkTypeBadge"; +import { useTranslation } from "next-i18next"; type Props = { link: LinkIncludingShortenedCollectionAndTags; @@ -30,6 +31,8 @@ export default function LinkCardCompact({ flipDropdown, editMode, }: Props) { + const { t } = useTranslation(); + const { collections } = useCollectionStore(); const { account } = useAccountStore(); const { links, setSelectedLinks, selectedLinks } = useLinkStore(); @@ -56,14 +59,6 @@ export default function LinkCardCompact({ } }; - let shortendURL; - - try { - shortendURL = new URL(link.url || "").host.toLowerCase(); - } catch (error) { - console.log(error); - } - const [collection, setCollection] = useState( collections.find( @@ -98,14 +93,12 @@ export default function LinkCardCompact({
    selectable ? handleCheckboxClick(link) : editMode - ? toast.error( - "You don't have permission to edit or delete this item." - ) + ? toast.error(t("link_selection_error")) : undefined } > @@ -124,43 +117,32 @@ export default function LinkCardCompact({ /> )} */}
    !editMode && window.open(generateLinkHref(link, account), "_blank") } >
    - +

    - {unescapeString(link.name || link.description) || link.url} + {link.name ? ( + unescapeString(link.name) + ) : ( +

    + +
    + )}

    -
    +
    {collection ? ( ) : undefined} - {link.url ? ( - { - e.stopPropagation(); - }} - className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100" - > - -

    {shortendURL}

    - - ) : ( -
    - {link.type} -
    - )} + {link.name && }
    diff --git a/components/LinkViews/LinkMasonry.tsx b/components/LinkViews/LinkMasonry.tsx new file mode 100644 index 0000000..369a004 --- /dev/null +++ b/components/LinkViews/LinkMasonry.tsx @@ -0,0 +1,271 @@ +import { + ArchivedFormat, + CollectionIncludingMembersAndLinkCount, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; +import { useEffect, useRef, useState } from "react"; +import useLinkStore from "@/store/links"; +import useCollectionStore from "@/store/collections"; +import unescapeString from "@/lib/client/unescapeString"; +import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; +import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; +import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; +import Image from "next/image"; +import { previewAvailable } from "@/lib/shared/getArchiveValidity"; +import Link from "next/link"; +import LinkIcon from "./LinkComponents/LinkIcon"; +import useOnScreen from "@/hooks/useOnScreen"; +import { generateLinkHref } from "@/lib/client/generateLinkHref"; +import useAccountStore from "@/store/account"; +import usePermissions from "@/hooks/usePermissions"; +import toast from "react-hot-toast"; +import LinkTypeBadge from "./LinkComponents/LinkTypeBadge"; +import { useTranslation } from "next-i18next"; + +type Props = { + link: LinkIncludingShortenedCollectionAndTags; + count: number; + className?: string; + flipDropdown?: boolean; + editMode?: boolean; +}; + +export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { + const { t } = useTranslation(); + + const { collections } = useCollectionStore(); + const { account } = useAccountStore(); + + const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore(); + + useEffect(() => { + if (!editMode) { + setSelectedLinks([]); + } + }, [editMode]); + + const handleCheckboxClick = ( + link: LinkIncludingShortenedCollectionAndTags + ) => { + if (selectedLinks.includes(link)) { + setSelectedLinks(selectedLinks.filter((e) => e !== link)); + } else { + setSelectedLinks([...selectedLinks, link]); + } + }; + + let shortendURL; + + try { + if (link.url) { + shortendURL = new URL(link.url).host.toLowerCase(); + } + } catch (error) { + console.log(error); + } + + const [collection, setCollection] = + useState( + 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(null); + const isVisible = useOnScreen(ref); + const permissions = usePermissions(collection?.id as number); + + useEffect(() => { + let interval: any; + + if ( + isVisible && + !link.preview?.startsWith("archives") && + link.preview !== "unavailable" + ) { + interval = setInterval(async () => { + getLink(link.id as number); + }, 5000); + } + + return () => { + if (interval) { + clearInterval(interval); + } + }; + }, [isVisible, link.preview]); + + const [showInfo, setShowInfo] = useState(false); + + const selectedStyle = selectedLinks.some( + (selectedLink) => selectedLink.id === link.id + ) + ? "border-primary bg-base-300" + : "border-neutral-content"; + + const selectable = + editMode && + (permissions === true || permissions?.canCreate || permissions?.canDelete); + + return ( +
    + selectable + ? handleCheckboxClick(link) + : editMode + ? toast.error(t("link_selection_error")) + : undefined + } + > +
    + !editMode && window.open(generateLinkHref(link, account), "_blank") + } + > +
    + {previewAvailable(link) ? ( + { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + ) : link.preview === "unavailable" ? null : ( +
    + )} + {link.type !== "image" && ( +
    + +
    + )} +
    + + {link.preview !== "unavailable" && ( +
    + )} + +
    +

    + {unescapeString(link.name)} +

    + + + + {link.description && ( +

    + {unescapeString(link.description)} +

    + )} + + {link.tags[0] && ( +
    + {link.tags.map((e, i) => ( + { + e.stopPropagation(); + }} + className="btn btn-xs btn-ghost truncate max-w-[19rem]" + > + #{e.name} + + ))} +
    + )} +
    + +
    + +
    + {collection && } + +
    +
    + + {showInfo && ( +
    +
    setShowInfo(!showInfo)} + className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10" + > + +
    +

    + {t("description")} +

    + +
    +

    + {link.description ? ( + unescapeString(link.description) + ) : ( + + {t("no_description")} + + )} +

    + {link.tags[0] && ( + <> +

    + {t("tags")} +

    + +
    + +
    +
    + {link.tags.map((e, i) => ( + { + e.stopPropagation(); + }} + className="btn btn-xs btn-ghost truncate max-w-[19rem]" + > + #{e.name} + + ))} +
    +
    + + )} +
    + )} + + setShowInfo(!showInfo)} + linkInfo={showInfo} + flipDropdown={flipDropdown} + /> +
    + ); +} diff --git a/components/Loader.tsx b/components/Loader.tsx deleted file mode 100644 index 87e6f3e..0000000 --- a/components/Loader.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Loading() { - return ( -
    -

    Loading...

    -
    - ); -} diff --git a/components/MobileNavigation.tsx b/components/MobileNavigation.tsx index 02fa08a..fda7f98 100644 --- a/components/MobileNavigation.tsx +++ b/components/MobileNavigation.tsx @@ -1,14 +1,16 @@ -import { dropdownTriggerer, isIphone } from "@/lib/client/utils"; +import { dropdownTriggerer, isIphone, isPWA } from "@/lib/client/utils"; import React from "react"; import { useState } from "react"; import NewLinkModal from "./ModalContent/NewLinkModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal"; import UploadFileModal from "./ModalContent/UploadFileModal"; import MobileNavigationButton from "./MobileNavigationButton"; +import { useTranslation } from "next-i18next"; type Props = {}; export default function MobileNavigation({}: Props) { + const { t } = useTranslation(); const [newLinkModal, setNewLinkModal] = useState(false); const [newCollectionModal, setNewCollectionModal] = useState(false); const [uploadFileModal, setUploadFileModal] = useState(false); @@ -20,7 +22,7 @@ export default function MobileNavigation({}: Props) { >
    @@ -49,21 +51,21 @@ export default function MobileNavigation({}: Props) { tabIndex={0} role="button" > - New Link + {t("new_link")} +
    + +
  • +
    { + (document?.activeElement as HTMLElement)?.blur(); + setUploadFileModal(true); + }} + tabIndex={0} + role="button" + > + {t("upload_file")}
  • - {/*
  • -
    { - (document?.activeElement as HTMLElement)?.blur(); - setUploadFileModal(true); - }} - tabIndex={0} - role="button" - > - Upload File -
    -
  • */}
  • { @@ -73,7 +75,7 @@ export default function MobileNavigation({}: Props) { tabIndex={0} role="button" > - New Collection + {t("new_collection")}
  • diff --git a/components/Modal.tsx b/components/Modal.tsx index 6d1bb9f..4691df7 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -6,9 +6,15 @@ type Props = { toggleModal: Function; children: ReactNode; className?: string; + dismissible?: boolean; }; -export default function Modal({ toggleModal, className, children }: Props) { +export default function Modal({ + toggleModal, + className, + children, + dismissible = true, +}: Props) { const [drawerIsOpen, setDrawerIsOpen] = React.useState(true); useEffect(() => { @@ -26,14 +32,23 @@ export default function Modal({ toggleModal, className, children }: Props) { return ( setTimeout(() => toggleModal(), 100)} + onClose={() => dismissible && setTimeout(() => toggleModal(), 100)} + dismissible={dismissible} > - setDrawerIsOpen(false)}> - -
    -
    + dismissible && setDrawerIsOpen(false)} + > + +
    +
    {children}
    @@ -44,20 +59,31 @@ export default function Modal({ toggleModal, className, children }: Props) { ); } else { return ( -
    +
    dismissible && toggleModal()} className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${ className || "" }`} > -
    -
    } - className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10" - > - -
    +
    + {dismissible && ( +
    } + className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10" + > + +
    + )} {children}
    diff --git a/components/ModalContent/BulkDeleteLinksModal.tsx b/components/ModalContent/BulkDeleteLinksModal.tsx index 6de26cd..5dc08f7 100644 --- a/components/ModalContent/BulkDeleteLinksModal.tsx +++ b/components/ModalContent/BulkDeleteLinksModal.tsx @@ -2,20 +2,19 @@ import React from "react"; import useLinkStore from "@/store/links"; import toast from "react-hot-toast"; import Modal from "../Modal"; +import Button from "../ui/Button"; +import { useTranslation } from "next-i18next"; type Props = { onClose: Function; }; export default function BulkDeleteLinksModal({ onClose }: Props) { + const { t } = useTranslation(); const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore(); const deleteLink = async () => { - const load = toast.loading( - `Deleting ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }...` - ); + const load = toast.loading(t("deleting")); const response = await deleteLinksById( selectedLinks.map((link) => link.id as number) @@ -24,12 +23,7 @@ export default function BulkDeleteLinksModal({ onClose }: Props) { toast.dismiss(load); if (response.ok) { - toast.success( - `Deleted ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }` - ); - + toast.success(t("deleted")); setSelectedLinks([]); onClose(); } else toast.error(response.data as string); @@ -38,37 +32,33 @@ export default function BulkDeleteLinksModal({ onClose }: Props) { return (

    - Delete {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""} + {selectedLinks.length === 1 + ? t("delete_link") + : t("delete_links", { count: selectedLinks.length })}

    - {selectedLinks.length > 1 ? ( -

    Are you sure you want to delete {selectedLinks.length} links?

    - ) : ( -

    Are you sure you want to delete this link?

    - )} +

    + {selectedLinks.length === 1 + ? t("link_deletion_confirmation_message") + : t("links_deletion_confirmation_message", { + count: selectedLinks.length, + })} +

    - - Warning: This action is irreversible! - + {t("warning_irreversible")}
    -

    - Hold the Shift key while clicking - 'Delete' to bypass this confirmation in the future. -

    +

    {t("shift_key_tip")}

    - + {t("delete")} +
    ); diff --git a/components/ModalContent/BulkEditLinksModal.tsx b/components/ModalContent/BulkEditLinksModal.tsx index 07b3914..5f3d31a 100644 --- a/components/ModalContent/BulkEditLinksModal.tsx +++ b/components/ModalContent/BulkEditLinksModal.tsx @@ -5,12 +5,14 @@ import useLinkStore from "@/store/links"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import toast from "react-hot-toast"; import Modal from "../Modal"; +import { useTranslation } from "next-i18next"; type Props = { onClose: Function; }; export default function BulkEditLinksModal({ onClose }: Props) { + const { t } = useTranslation(); const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore(); const [submitLoader, setSubmitLoader] = useState(false); const [removePreviousTags, setRemovePreviousTags] = useState(false); @@ -20,7 +22,6 @@ export default function BulkEditLinksModal({ onClose }: Props) { const setCollection = (e: any) => { const collectionId = e?.value || null; - console.log(updatedValues); setUpdatedValues((prevValues) => ({ ...prevValues, collectionId })); }; @@ -33,7 +34,7 @@ export default function BulkEditLinksModal({ onClose }: Props) { if (!submitLoader) { setSubmitLoader(true); - const load = toast.loading("Updating..."); + const load = toast.loading(t("updating")); const response = await updateLinks( selectedLinks, @@ -44,7 +45,7 @@ export default function BulkEditLinksModal({ onClose }: Props) { toast.dismiss(load); if (response.ok) { - toast.success(`Updated!`); + toast.success(t("updated")); setSelectedLinks([]); onClose(); } else toast.error(response.data as string); @@ -57,13 +58,15 @@ export default function BulkEditLinksModal({ onClose }: Props) { return (

    - Edit {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""} + {selectedLinks.length === 1 + ? t("edit_link") + : t("edit_links", { count: selectedLinks.length })}

    -

    Move to Collection

    +

    {t("move_to_collection")}

    -

    Add Tags

    +

    {t("add_tags")}

    @@ -84,7 +87,7 @@ export default function BulkEditLinksModal({ onClose }: Props) { checked={removePreviousTags} onChange={(e) => setRemovePreviousTags(e.target.checked)} /> - Remove previous tags + {t("remove_previous_tags")}
    @@ -94,7 +97,7 @@ export default function BulkEditLinksModal({ onClose }: Props) { className="btn btn-accent dark:border-violet-400 text-white" onClick={submit} > - Save Changes + {t("save_changes")}
    diff --git a/components/ModalContent/DeleteCollectionModal.tsx b/components/ModalContent/DeleteCollectionModal.tsx index 5407f68..504d3a3 100644 --- a/components/ModalContent/DeleteCollectionModal.tsx +++ b/components/ModalContent/DeleteCollectionModal.tsx @@ -6,6 +6,8 @@ import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { useRouter } from "next/router"; import usePermissions from "@/hooks/usePermissions"; import Modal from "../Modal"; +import Button from "../ui/Button"; +import { useTranslation } from "next-i18next"; type Props = { onClose: Function; @@ -16,42 +18,40 @@ export default function DeleteCollectionModal({ onClose, activeCollection, }: Props) { + const { t } = useTranslation(); const [collection, setCollection] = useState(activeCollection); + const [submitLoader, setSubmitLoader] = useState(false); + const { removeCollection } = useCollectionStore(); + const router = useRouter(); + const [inputField, setInputField] = useState(""); + const permissions = usePermissions(collection.id as number); useEffect(() => { setCollection(activeCollection); }, []); - const [submitLoader, setSubmitLoader] = useState(false); - const { removeCollection } = useCollectionStore(); - const router = useRouter(); - const [inputField, setInputField] = useState(""); - - const permissions = usePermissions(collection.id as number); - const submit = async () => { - if (permissions === true) if (collection.name !== inputField) return null; - + if (permissions === true && collection.name !== inputField) return; if (!submitLoader) { setSubmitLoader(true); if (!collection) return null; setSubmitLoader(true); - const load = toast.loading("Deleting..."); + const load = toast.loading(t("deleting_collection")); - let response; - - response = await removeCollection(collection.id as any); + let response = await removeCollection(collection.id as number); toast.dismiss(load); if (response.ok) { - toast.success(`Deleted.`); + toast.success(t("deleted")); onClose(); router.push("/collections"); - } else toast.error(response.data as string); + } else { + toast.error(response.data as string); + } setSubmitLoader(false); } @@ -60,7 +60,7 @@ export default function DeleteCollectionModal({ return (

    - {permissions === true ? "Delete" : "Leave"} Collection + {permissions === true ? t("delete_collection") : t("leave_collection")}

    @@ -68,48 +68,37 @@ export default function DeleteCollectionModal({
    {permissions === true ? ( <> -
    -

    - To confirm, type " - {collection.name} - " in the box below: -

    - - setInputField(e.target.value)} - placeholder={`Type "${collection.name}" Here.`} - className="w-3/4 mx-auto" - /> -
    +

    {t("confirm_deletion_prompt", { name: collection.name })}

    + setInputField(e.target.value)} + placeholder={t("type_name_placeholder", { + name: collection.name, + })} + className="w-3/4 mx-auto" + />
    - Warning: Deleting this collection will permanently erase - all its contents, and it will become inaccessible to everyone, - including members with previous access. + {t("warning")}: + {t("deletion_warning")}
    ) : ( -

    Click the button below to leave the current collection.

    +

    {t("leave_prompt")}

    )} - + {permissions === true ? t("delete") : t("leave")} +
    ); diff --git a/components/ModalContent/DeleteLinkModal.tsx b/components/ModalContent/DeleteLinkModal.tsx index 1a3a476..92de104 100644 --- a/components/ModalContent/DeleteLinkModal.tsx +++ b/components/ModalContent/DeleteLinkModal.tsx @@ -4,6 +4,8 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import toast from "react-hot-toast"; import Modal from "../Modal"; import { useRouter } from "next/router"; +import Button from "../ui/Button"; +import { useTranslation } from "next-i18next"; type Props = { onClose: Function; @@ -11,11 +13,10 @@ type Props = { }; export default function DeleteLinkModal({ onClose, activeLink }: Props) { + const { t } = useTranslation(); const [link, setLink] = useState(activeLink); - const { removeLink } = useLinkStore(); - const router = useRouter(); useEffect(() => { @@ -23,13 +24,13 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) { }, []); const deleteLink = async () => { - const load = toast.loading("Deleting..."); + const load = toast.loading(t("deleting")); const response = await removeLink(link.id as number); toast.dismiss(load); - response.ok && toast.success(`Link Deleted.`); + response.ok && toast.success(t("deleted")); if (router.pathname.startsWith("/links/[id]")) { router.push("/dashboard"); @@ -40,32 +41,26 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) { return ( -

    Delete Link

    +

    {t("delete_link")}

    -

    Are you sure you want to delete this Link?

    +

    {t("link_deletion_confirmation_message")}

    - Warning: This action is irreversible! + {t("warning")}: {t("irreversible_warning")}
    -

    - Hold the Shift key while clicking - 'Delete' to bypass this confirmation in the future. -

    +

    {t("shift_key_tip")}

    - + {t("delete")} +
    ); diff --git a/components/ModalContent/DeleteUserModal.tsx b/components/ModalContent/DeleteUserModal.tsx new file mode 100644 index 0000000..73a81ed --- /dev/null +++ b/components/ModalContent/DeleteUserModal.tsx @@ -0,0 +1,51 @@ +import toast from "react-hot-toast"; +import Modal from "../Modal"; +import useUserStore from "@/store/admin/users"; +import Button from "../ui/Button"; +import { useTranslation } from "next-i18next"; + +type Props = { + onClose: Function; + userId: number; +}; + +export default function DeleteUserModal({ onClose, userId }: Props) { + const { t } = useTranslation(); + const { removeUser } = useUserStore(); + + const deleteUser = async () => { + const load = toast.loading(t("deleting_user")); + + const response = await removeUser(userId); + + toast.dismiss(load); + + response.ok && toast.success(t("user_deleted")); + + onClose(); + }; + + return ( + +

    {t("delete_user")}

    + +
    + +
    +

    {t("confirm_user_deletion")}

    + +
    + + + {t("warning")}: {t("irreversible_action_warning")} + +
    + + +
    +
    + ); +} diff --git a/components/ModalContent/EditCollectionModal.tsx b/components/ModalContent/EditCollectionModal.tsx index fbbf53e..b2105f5 100644 --- a/components/ModalContent/EditCollectionModal.tsx +++ b/components/ModalContent/EditCollectionModal.tsx @@ -5,6 +5,7 @@ import toast from "react-hot-toast"; import { HexColorPicker } from "react-colorful"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import Modal from "../Modal"; +import { useTranslation } from "next-i18next"; type Props = { onClose: Function; @@ -15,6 +16,7 @@ export default function EditCollectionModal({ onClose, activeCollection, }: Props) { + const { t } = useTranslation(); const [collection, setCollection] = useState(activeCollection); @@ -28,16 +30,14 @@ export default function EditCollectionModal({ setSubmitLoader(true); - const load = toast.loading("Updating..."); + const load = toast.loading(t("updating_collection")); - let response; - - response = await updateCollection(collection as any); + let response = await updateCollection(collection as any); toast.dismiss(load); if (response.ok) { - toast.success(`Updated!`); + toast.success(t("updated")); onClose(); } else toast.error(response.data as string); @@ -47,29 +47,35 @@ export default function EditCollectionModal({ return ( -

    Edit Collection Info

    +

    {t("edit_collection_info")}

    -

    Name

    +

    {t("name")}

    setCollection({ ...collection, name: e.target.value }) } />
    -

    Color

    -
    +

    {t("color")}

    +
    + + setCollection({ ...collection, color }) + } + />
    - Reset + {t("reset")}
    - setCollection({ ...collection, color: e })} - />
    -

    Description

    +

    {t("description")}