diff --git a/.env.sample b/.env.sample index 3cedb1df..c34fff22 100644 --- a/.env.sample +++ b/.env.sample @@ -35,6 +35,7 @@ READABILITY_MAX_BUFFER= PREVIEW_MAX_BUFFER= IMPORT_LIMIT= PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH= +MAX_WORKERS= # AWS S3 Settings SPACES_KEY= diff --git a/components/CollectionListing.tsx b/components/CollectionListing.tsx index e83d388a..72fe8448 100644 --- a/components/CollectionListing.tsx +++ b/components/CollectionListing.tsx @@ -27,7 +27,7 @@ const CollectionListing = () => { const updateCollection = useUpdateCollection(); const { data: collections = [], isLoading } = useCollections(); - const { data: user = {} } = useUser(); + const { data: user = {}, refetch } = useUser(); const updateUser = useUpdateUser(); const router = useRouter(); @@ -36,10 +36,7 @@ const CollectionListing = () => { const [tree, setTree] = useState(); const initialTree = useMemo(() => { - if ( - // !tree && - collections.length > 0 - ) { + if (collections.length > 0) { return buildTreeFromCollections( collections, router, @@ -49,12 +46,12 @@ const CollectionListing = () => { }, [collections, user, router]); useEffect(() => { - // if (!tree) setTree(initialTree); }, [initialTree]); useEffect(() => { if (user.username) { + refetch(); if ( (!user.collectionOrder || user.collectionOrder.length === 0) && collections.length > 0 @@ -62,11 +59,7 @@ const CollectionListing = () => { updateUser.mutate({ ...user, collectionOrder: collections - .filter( - (e) => - e.parentId === null || - !collections.find((i) => i.id === e.parentId) - ) // Filter out collections with non-null parentId + .filter((e) => e.parentId === null) .map((e) => e.id as number), }); else { @@ -100,7 +93,7 @@ const CollectionListing = () => { } } } - }, [collections]); + }, [user, collections]); const onExpand = (movedCollectionId: ItemId) => { setTree((currentTree) => diff --git a/components/DashboardItem.tsx b/components/DashboardItem.tsx index 337fc367..798f2b7b 100644 --- a/components/DashboardItem.tsx +++ b/components/DashboardItem.tsx @@ -8,13 +8,15 @@ export default function dashboardItem({ icon: string; }) { return ( -
-
+
+
-

{name}

-

{value}

+

{name}

+

+ {value || 0} +

); diff --git a/components/Drawer.tsx b/components/Drawer.tsx new file mode 100644 index 00000000..7400e44f --- /dev/null +++ b/components/Drawer.tsx @@ -0,0 +1,81 @@ +import React, { ReactNode, useEffect } from "react"; +import { Drawer as D } from "vaul"; +import clsx from "clsx"; + +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 && setDrawerIsOpen(false)} + onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()} + dismissible={dismissible} + > + + + +
+
+ {children} +
+ + + + ); + } else { + return ( + dismissible && setDrawerIsOpen(false)} + onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()} + dismissible={dismissible} + direction="right" + > + + + +
+ {children} +
+
+
+
+ ); + } +} diff --git a/components/IconGrid.tsx b/components/IconGrid.tsx new file mode 100644 index 00000000..9a2f8830 --- /dev/null +++ b/components/IconGrid.tsx @@ -0,0 +1,91 @@ +import { icons } from "@/lib/client/icons"; +import Fuse from "fuse.js"; +import { forwardRef, useMemo } from "react"; +import { FixedSizeGrid as Grid } from "react-window"; + +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 filteredIcons = useMemo(() => { + if (!query) { + return icons; + } + return fuse.search(query).map((result) => result.item); + }, [query]); + + const columnCount = 6; + const rowCount = Math.ceil(filteredIcons.length / columnCount); + const GUTTER_SIZE = 5; + + const Cell = ({ columnIndex, rowIndex, style }: any) => { + const index = rowIndex * columnCount + columnIndex; + if (index >= filteredIcons.length) return null; // Prevent overflow + + const icon = filteredIcons[index]; + const IconComponent = icon.Icon; + + return ( +
setIconName(icon.pascal_name)} + className={`cursor-pointer p-[6px] rounded-lg bg-base-100 w-full ${ + icon.pascal_name === iconName + ? "outline outline-1 outline-primary" + : "" + }`} + > + +
+ ); + }; + + const InnerElementType = forwardRef(({ style, ...rest }: any, ref) => ( +
+ )); + + InnerElementType.displayName = "InnerElementType"; + + return ( + + {Cell} + + ); +}; + +export default IconGrid; diff --git a/components/IconPopover.tsx b/components/IconPopover.tsx new file mode 100644 index 00000000..6b13d211 --- /dev/null +++ b/components/IconPopover.tsx @@ -0,0 +1,147 @@ +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-3 w-[22.5rem] rounded-lg shadow-md" + )} + > +
+ setQuery(e.target.value)} + /> + +
+ +
+ +
+ setColor(e)} + className="border border-neutral-content rounded-lg" + /> + +
+ + + + + + +
+
+
+
} + > + {t("reset_defaults")} +
+

{t("click_out_to_apply")}

+
+
+
+ ); +}; + +export default IconPopover; diff --git a/components/InputSelect/styles.ts b/components/InputSelect/styles.ts index 96aad6d9..b3dd2971 100644 --- a/components/InputSelect/styles.ts +++ b/components/InputSelect/styles.ts @@ -16,6 +16,10 @@ export const styles: StylesConfig = { }, transition: "all 50ms", }), + menu: (styles) => ({ + ...styles, + zIndex: 10, + }), control: (styles, state) => ({ ...styles, fontFamily: font, diff --git a/components/LinkDetails.tsx b/components/LinkDetails.tsx new file mode 100644 index 00000000..5fb7a6ca --- /dev/null +++ b/components/LinkDetails.tsx @@ -0,0 +1,687 @@ +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; + onUpdateArchive?: Function; +}; + +export default function LinkDetails({ + className, + activeLink, + standalone, + mode = "view", + setMode, + onUpdateArchive, +}: 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) && + !isPublicRoute && ( +
+ +
+ )} +
+ + {!standalone && + (permissions === true || permissions?.canUpdate) && + !isPublicRoute ? ( +
+
+ 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" && ( +
+

+ {unescapeString(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")}

+ )} +
+ ) : ( +