From 16024f40be9868a9059bd853d03a8be4f57528d0 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sun, 29 Oct 2023 00:57:24 -0400 Subject: [PATCH] added new api route + fixed dropdown --- README.md | 1 - components/CollectionCard.tsx | 148 +++++++------- components/Dropdown.tsx | 61 +++--- components/LinkCard.tsx | 188 +++++++++--------- components/Modal/Link/LinkDetails.tsx | 46 ++++- components/Modal/Link/index.tsx | 9 +- components/SortDropdown.tsx | 2 +- lib/api/archive.ts | 22 +- .../controllers/links/linkId/getLinkById.ts | 48 +++++ .../links/linkId/updateLinkById.ts | 2 +- pages/api/v1/links/[id]/index.ts | 8 +- store/links.ts | 16 ++ 12 files changed, 348 insertions(+), 203 deletions(-) create mode 100644 lib/api/controllers/links/linkId/getLinkById.ts diff --git a/README.md b/README.md index a830530..cf74430 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,6 @@ Here are the other ways to support/cheer this project: - Starring this repository. - Joining us on [Discord](https://discord.com/invite/CtuYV47nuJ). -- Following @daniel31x13 on [Mastodon](https://mastodon.social/@daniel31x13), [Twitter](https://twitter.com/daniel31x13) and [GitHub](https://github.com/daniel31x13). - Referring Linkwarden to a friend. If you did any of the above, Thanksss! Otherwise thanks. diff --git a/components/CollectionCard.tsx b/components/CollectionCard.tsx index 7d0442e..c382f6e 100644 --- a/components/CollectionCard.tsx +++ b/components/CollectionCard.tsx @@ -15,6 +15,13 @@ type Props = { className?: string; }; +type DropdownTrigger = + | { + x: number; + y: number; + } + | false; + export default function CollectionCard({ collection, className }: Props) { const { setModal } = useModalStore(); @@ -29,83 +36,86 @@ export default function CollectionCard({ collection, className }: Props) { } ); - const [expandDropdown, setExpandDropdown] = useState(false); + const [expandDropdown, setExpandDropdown] = useState(false); const permissions = usePermissions(collection.id as number); return ( -
+ <>
setExpandDropdown(!expandDropdown)} - id={"expand-dropdown" + collection.id} - className="inline-flex absolute top-5 right-5 rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" + style={{ + backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${ + theme === "dark" ? "#262626" : "#f3f4f6" + } 50%, ${theme === "dark" ? "#262626" : "#f9fafb"} 100%)`, + }} + className={`border border-solid border-sky-100 dark:border-neutral-700 self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none hover:opacity-80 group relative ${ + className || "" + }`} > - setExpandDropdown({ x: e.clientX, y: e.clientY })} id={"expand-dropdown" + collection.id} - className="w-5 h-5 text-gray-500 dark:text-gray-300" - /> -
- -

- {collection.name} -

-
-
- {collection.members - .sort((a, b) => (a.userId as number) - (b.userId as number)) - .map((e, i) => { - return ( - - ); - }) - .slice(0, 4)} - {collection.members.length - 4 > 0 ? ( -
- +{collection.members.length - 4} -
- ) : null} -
-
-
- {collection.isPublic ? ( - - ) : undefined} - - {collection._count && collection._count.links} -
-
- -

{formattedDate}

-
-
+ className="inline-flex absolute top-5 right-5 rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" + > +
- + +

+ {collection.name} +

+
+
+ {collection.members + .sort((a, b) => (a.userId as number) - (b.userId as number)) + .map((e, i) => { + return ( + + ); + }) + .slice(0, 4)} + {collection.members.length - 4 > 0 ? ( +
+ +{collection.members.length - 4} +
+ ) : null} +
+
+
+ {collection.isPublic ? ( + + ) : undefined} + + {collection._count && collection._count.links} +
+
+ +

{formattedDate}

+
+
+
+ +
{expandDropdown ? ( ) : null} - + ); } diff --git a/components/Dropdown.tsx b/components/Dropdown.tsx index aac1ad6..19e2778 100644 --- a/components/Dropdown.tsx +++ b/components/Dropdown.tsx @@ -19,9 +19,9 @@ type Props = { onClickOutside: Function; className?: string; items: MenuItem[]; - points: { x: number; y: number }; + points?: { x: number; y: number }; style?: React.CSSProperties; - width: number; // in rem + width?: number; // in rem }; export default function Dropdown({ @@ -33,6 +33,7 @@ export default function Dropdown({ }: Props) { const [pos, setPos] = useState<{ x: number; y: number }>(); const [dropdownHeight, setDropdownHeight] = useState(); + const [dropdownWidth, setDropdownWidth] = useState(); function convertRemToPixels(rem: number) { return ( @@ -41,48 +42,46 @@ export default function Dropdown({ } useEffect(() => { - const dropdownWidth = convertRemToPixels(width); + if (points) { + let finalX = points.x; + let finalY = points.y; - let finalX = points.x; - let finalY = points.y; + // Check for x-axis overflow (left side) + if (dropdownWidth && points.x + dropdownWidth > window.innerWidth) { + finalX = points.x - dropdownWidth; + } - // Check for x-axis overflow (left side) - if (points.x + dropdownWidth > window.innerWidth) { - finalX = points.x - dropdownWidth; + // Check for y-axis overflow (bottom side) + if (dropdownHeight && points.y + dropdownHeight > window.innerHeight) { + finalY = + window.innerHeight - + (dropdownHeight + (window.innerHeight - points.y)); + } + + setPos({ x: finalX, y: finalY }); } - - // Check for y-axis overflow (bottom side) - if (dropdownHeight && points.y + dropdownHeight > window.innerHeight) { - finalY = - window.innerHeight - (dropdownHeight + (window.innerHeight - points.y)); - } - - setPos({ x: finalX, y: finalY }); }, [points, width, dropdownHeight]); - useEffect(() => { - const dropdownWidth = convertRemToPixels(width); - - if (points.x + dropdownWidth > window.innerWidth) { - setPos({ x: points.x - dropdownWidth, y: points.y }); - } else setPos(points); - }, [points, width]); - return ( - pos && ( + (!points || pos) && ( { setDropdownHeight(e.height); + setDropdownWidth(e.width); }} - style={{ - position: "fixed", - top: `${pos?.y}px`, - left: `${pos?.x}px`, - }} + style={ + points + ? { + position: "fixed", + top: `${pos?.y}px`, + left: `${pos?.x}px`, + } + : undefined + } onClickOutside={onClickOutside} className={`${ className || "" - } py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20 w-[${width}rem]`} + } py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`} > {items.map((e, i) => { const inner = e && ( diff --git a/components/LinkCard.tsx b/components/LinkCard.tsx index 5809025..e972d45 100644 --- a/components/LinkCard.tsx +++ b/components/LinkCard.tsx @@ -71,7 +71,7 @@ export default function LinkCard({ link, count, className }: Props) { ); }, [collections, links]); - const { removeLink, updateLink } = useLinkStore(); + const { removeLink, updateLink, getLink } = useLinkStore(); const pinLink = async () => { const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0]; @@ -92,7 +92,7 @@ export default function LinkCard({ link, count, className }: Props) { }; const updateArchive = async () => { - const load = toast.loading("Applying..."); + const load = toast.loading("Sending request..."); setExpandDropdown(false); @@ -104,8 +104,10 @@ export default function LinkCard({ link, count, className }: Props) { toast.dismiss(load); - if (response.ok) toast.success(`Link is being archived.`); - else toast.error(data); + if (response.ok) { + toast.success(`Link is being archived...`); + getLink(link.id as number); + } else toast.error(data); }; const deleteLink = async () => { @@ -131,98 +133,100 @@ export default function LinkCard({ link, count, className }: Props) { ); return ( -
- {(permissions === true || - permissions?.canUpdate || - permissions?.canDelete) && ( -
{ - setExpandDropdown({ x: e.clientX, y: e.clientY }); - }} - id={"expand-dropdown" + link.id} - className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-5 top-5 z-10 duration-100 p-1" - > - -
- )} - + <>
{ - setModal({ - modal: "LINK", - state: true, - method: "UPDATE", - isOwnerOrMod: - permissions === true || (permissions?.canUpdate as boolean), - active: link, - }); - }} - className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5" + className={`h-fit border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-2xl relative group ${ + className || "" + }`} > - {url && ( - { - const target = e.target as HTMLElement; - target.style.display = "none"; + {(permissions === true || + permissions?.canUpdate || + permissions?.canDelete) && ( +
{ + setExpandDropdown({ x: e.clientX, y: e.clientY }); }} - /> + id={"expand-dropdown" + link.id} + className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-5 top-5 z-10 duration-100 p-1" + > + +
)} -
-
-
-

- {count + 1} -

-

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

-
- { - e.stopPropagation(); +
{ + setModal({ + modal: "LINK", + state: true, + method: "UPDATE", + isOwnerOrMod: + permissions === true || (permissions?.canUpdate as boolean), + active: link, + }); + }} + className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5" + > + {url && ( + { + const target = e.target as HTMLElement; + target.style.display = "none"; }} - className="flex items-center gap-1 max-w-full w-fit my-3 hover:opacity-70 duration-100" - > - -

- {collection?.name} -

- - { - e.stopPropagation(); - }} - className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100" - > - -

{shortendURL}

- -
- -

{formattedDate}

+ /> + )} + +
+
+
+

+ {count + 1} +

+

+ {unescapeString(link.name || link.description)} +

+
+ { + e.stopPropagation(); + }} + className="flex items-center gap-1 max-w-full w-fit my-3 hover:opacity-70 duration-100" + > + +

+ {collection?.name} +

+ + { + e.stopPropagation(); + }} + className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100" + > + +

{shortendURL}

+ +
+ +

{formattedDate}

+
@@ -275,9 +279,9 @@ export default function LinkCard({ link, count, className }: Props) { if (target.id !== "expand-dropdown" + link.id) setExpandDropdown(false); }} - width={10} + className="w-40" /> ) : null} -
+ ); } diff --git a/components/Modal/Link/LinkDetails.tsx b/components/Modal/Link/LinkDetails.tsx index 55328f6..ddf2c5b 100644 --- a/components/Modal/Link/LinkDetails.tsx +++ b/components/Modal/Link/LinkDetails.tsx @@ -23,15 +23,49 @@ import { import isValidUrl from "@/lib/client/isValidUrl"; import { useTheme } from "next-themes"; import unescapeString from "@/lib/client/unescapeString"; +import useLinkStore from "@/store/links"; type Props = { - link: LinkIncludingShortenedCollectionAndTags; + linkId: number; isOwnerOrMod: boolean; }; -export default function LinkDetails({ link, isOwnerOrMod }: Props) { +export default function LinkDetails({ linkId, isOwnerOrMod }: Props) { const { theme } = useTheme(); + const { links, getLink } = useLinkStore(); + + const [link, setLink] = useState( + links.find( + (e) => e.id === linkId + ) as LinkIncludingShortenedCollectionAndTags + ); + + useEffect(() => { + setLink( + links.find( + (e) => e.id === linkId + ) as LinkIncludingShortenedCollectionAndTags + ); + }, [links]); + + useEffect(() => { + let interval: NodeJS.Timer | undefined; + if (link.screenshotPath === "pending" || link.pdfPath === "pending") { + interval = setInterval(() => getLink(link.id as number), 5000); + } else { + if (interval) { + clearInterval(interval); + } + } + + return () => { + if (interval) { + clearInterval(interval); + } + }; + }, [link.screenshotPath, link.pdfPath]); + const [imageError, setImageError] = useState(false); const formattedDate = new Date(link.createdAt as string).toLocaleString( "en-US", @@ -59,6 +93,14 @@ export default function LinkDetails({ link, isOwnerOrMod }: Props) { ); }, [collections]); + useEffect(() => { + setCollection( + collections.find( + (e) => e.id === link.collection.id + ) as CollectionIncludingMembersAndLinkCount + ); + }, [collections]); + const [colorPalette, setColorPalette] = useState(); const colorThief = new ColorThief(); diff --git a/components/Modal/Link/index.tsx b/components/Modal/Link/index.tsx index 3f9f243..2ebf08d 100644 --- a/components/Modal/Link/index.tsx +++ b/components/Modal/Link/index.tsx @@ -64,7 +64,10 @@ export default function LinkModal({ {activeLink && method === "UPDATE" && ( - + )} @@ -73,7 +76,9 @@ export default function LinkModal({ ) : (

Sort by diff --git a/lib/api/archive.ts b/lib/api/archive.ts index b257940..e92873f 100644 --- a/lib/api/archive.ts +++ b/lib/api/archive.ts @@ -14,13 +14,29 @@ export default async function archive( }, }); + // const checkExistingLink = await prisma.link.findFirst({ + // where: { + // id: linkId, + // OR: [ + // { + // screenshotPath: "pending", + // }, + // { + // pdfPath: "pending", + // }, + // ], + // }, + // }); + + // if (checkExistingLink) return "A request has already been made."; + const link = await prisma.link.update({ where: { id: linkId, }, data: { - screenshotPath: "pending", - pdfPath: "pending", + screenshotPath: user?.archiveAsScreenshot ? "pending" : null, + pdfPath: user?.archiveAsPDF ? "pending" : null, }, }); @@ -88,8 +104,8 @@ export default async function archive( await browser.close(); } catch (err) { - console.log(err); await browser.close(); + return err; } } } diff --git a/lib/api/controllers/links/linkId/getLinkById.ts b/lib/api/controllers/links/linkId/getLinkById.ts new file mode 100644 index 0000000..fb7d0c8 --- /dev/null +++ b/lib/api/controllers/links/linkId/getLinkById.ts @@ -0,0 +1,48 @@ +import { prisma } from "@/lib/api/db"; +import { Collection, Link, UsersAndCollections } from "@prisma/client"; +import getPermission from "@/lib/api/getPermission"; + +export default async function getLinkById(userId: number, linkId: number) { + if (!linkId) + return { + response: "Please choose a valid link.", + status: 401, + }; + + const collectionIsAccessible = (await getPermission({ userId, linkId })) as + | (Collection & { + members: UsersAndCollections[]; + }) + | null; + + const memberHasAccess = collectionIsAccessible?.members.some( + (e: UsersAndCollections) => e.userId === userId && e.canUpdate + ); + + const isCollectionOwner = collectionIsAccessible?.ownerId === userId; + + if (collectionIsAccessible?.ownerId !== userId && !memberHasAccess) + return { + response: "Collection is not accessible.", + status: 401, + }; + else { + const updatedLink = await prisma.link.findUnique({ + where: { + id: linkId, + }, + include: { + tags: true, + collection: true, + pinnedBy: isCollectionOwner + ? { + where: { id: userId }, + select: { id: true }, + } + : undefined, + }, + }); + + return { response: updatedLink, status: 200 }; + } +} diff --git a/lib/api/controllers/links/linkId/updateLinkById.ts b/lib/api/controllers/links/linkId/updateLinkById.ts index 9ed6464..588978f 100644 --- a/lib/api/controllers/links/linkId/updateLinkById.ts +++ b/lib/api/controllers/links/linkId/updateLinkById.ts @@ -4,7 +4,7 @@ import { Collection, Link, UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; import moveFile from "@/lib/api/storage/moveFile"; -export default async function updateLink( +export default async function updateLinkById( userId: number, linkId: number, data: LinkIncludingShortenedCollectionAndTags diff --git a/pages/api/v1/links/[id]/index.ts b/pages/api/v1/links/[id]/index.ts index 9847f2a..748e009 100644 --- a/pages/api/v1/links/[id]/index.ts +++ b/pages/api/v1/links/[id]/index.ts @@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/pages/api/v1/auth/[...nextauth]"; import deleteLinkById from "@/lib/api/controllers/links/linkId/deleteLinkById"; import updateLinkById from "@/lib/api/controllers/links/linkId/updateLinkById"; +import getLinkById from "@/lib/api/controllers/links/linkId/getLinkById"; export default async function links(req: NextApiRequest, res: NextApiResponse) { const session = await getServerSession(req, res, authOptions); @@ -15,7 +16,12 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) { "You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.", }); - if (req.method === "PUT") { + if (req.method === "GET") { + const updated = await getLinkById(session.user.id, Number(req.query.id)); + return res.status(updated.status).json({ + response: updated.response, + }); + } else if (req.method === "PUT") { const updated = await updateLinkById( session.user.id, Number(req.query.id), diff --git a/store/links.ts b/store/links.ts index b106530..b5e14ff 100644 --- a/store/links.ts +++ b/store/links.ts @@ -17,6 +17,7 @@ type LinkStore = { addLink: ( body: LinkIncludingShortenedCollectionAndTags ) => Promise; + getLink: (linkId: number) => Promise; updateLink: ( link: LinkIncludingShortenedCollectionAndTags ) => Promise; @@ -65,6 +66,21 @@ const useLinkStore = create()((set) => ({ return { ok: response.ok, data: data.response }; }, + getLink: async (linkId) => { + const response = await fetch(`/api/v1/links/${linkId}`); + + const data = await response.json(); + + if (response.ok) { + set((state) => ({ + links: state.links.map((e) => + e.id === data.response.id ? data.response : e + ), + })); + } + + return { ok: response.ok, data: data.response }; + }, updateLink: async (link) => { const response = await fetch(`/api/v1/links/${link.id}`, { body: JSON.stringify(link),