From 4b1017f45b368c50b34eff6628376b0439c460ed Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Wed, 6 Dec 2023 16:13:11 -0500 Subject: [PATCH] [WIP] --- components/LinkCard.tsx | 36 ++- components/ModalContent/ExpandedLink.tsx | 254 ++++++++++++++++++ components/ModalContent/NewLinkModal.tsx | 1 - .../ModalContent/PreservedFormatsModal.tsx | 237 ++++++++++++++++ components/Navbar.tsx | 4 +- components/SettingsSidebar.tsx | 2 +- layouts/LinkLayout.tsx | 61 ++--- lib/api/controllers/links/postLink.ts | 2 +- lib/{api => shared}/getTitle.ts | 0 pages/api/v1/archives/[linkId].ts | 155 +++++------ pages/links/[id].tsx | 132 ++++----- store/links.ts | 2 + 12 files changed, 695 insertions(+), 191 deletions(-) create mode 100644 components/ModalContent/ExpandedLink.tsx create mode 100644 components/ModalContent/PreservedFormatsModal.tsx rename lib/{api => shared}/getTitle.ts (100%) diff --git a/components/LinkCard.tsx b/components/LinkCard.tsx index 28fb555..73e652f 100644 --- a/components/LinkCard.tsx +++ b/components/LinkCard.tsx @@ -26,6 +26,8 @@ import unescapeString from "@/lib/client/unescapeString"; import { useRouter } from "next/router"; import EditLinkModal from "./ModalContent/EditLinkModal"; import DeleteLinkModal from "./ModalContent/DeleteLinkModal"; +import ExpandedLink from "./ModalContent/ExpandedLink"; +import PreservedFormatsModal from "./ModalContent/PreservedFormatsModal"; type Props = { link: LinkIncludingShortenedCollectionAndTags; @@ -126,6 +128,8 @@ export default function LinkCard({ link, count, className }: Props) { const [editLinkModal, setEditLinkModal] = useState(false); const [deleteLinkModal, setDeleteLinkModal] = useState(false); + const [preservedFormatsModal, setPreservedFormatsModal] = useState(false); + const [expandedLink, setExpandedLink] = useState(false); return (
{ (document?.activeElement as HTMLElement)?.blur(); - updateArchive(); + setPreservedFormatsModal(true); + // updateArchive(); }} > - Refresh Link + Preserved Formats
) : undefined} @@ -212,8 +217,12 @@ export default function LinkCard({ link, count, className }: Props) { ) : undefined} -
router.push("/links/" + link.id)} + router.push("/links/" + link.id) + // // setExpandedLink(true) + // } className="flex flex-col justify-between cursor-pointer h-full w-full gap-1 p-3" > {link.url && url ? ( @@ -232,12 +241,12 @@ export default function LinkCard({ link, count, className }: Props) { ) : link.type === "pdf" ? ( ) : link.type === "image" ? ( ) : undefined} @@ -248,14 +257,14 @@ export default function LinkCard({ link, count, className }: Props) {

- {link.type === "url" ? ( + {link.url ? ( { e.stopPropagation(); }} - className="flex items-center gap-1 max-w-full w-fit text-neutral hover:opacity-70 duration-100" + className="flex items-center gap-1 max-w-full w-fit text-neutral hover:opacity-60 duration-100" >

{shortendURL}

@@ -304,7 +313,7 @@ export default function LinkCard({ link, count, className }: Props) { ) : (

No Tags

)} */} - + {editLinkModal ? ( setEditLinkModal(false)} @@ -317,6 +326,15 @@ export default function LinkCard({ link, count, className }: Props) { activeLink={link} /> ) : undefined} + {preservedFormatsModal ? ( + setPreservedFormatsModal(false)} + activeLink={link} + /> + ) : undefined} + {/* {expandedLink ? ( + setExpandedLink(false)} link={link} /> + ) : undefined} */} ); } diff --git a/components/ModalContent/ExpandedLink.tsx b/components/ModalContent/ExpandedLink.tsx new file mode 100644 index 0000000..464d568 --- /dev/null +++ b/components/ModalContent/ExpandedLink.tsx @@ -0,0 +1,254 @@ +import { + CollectionIncludingMembersAndLinkCount, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; +import Image from "next/image"; +import ColorThief, { RGBColor } from "colorthief"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faArrowUpRightFromSquare, + faBoxArchive, + faCloudArrowDown, + faFolder, +} from "@fortawesome/free-solid-svg-icons"; +import useCollectionStore from "@/store/collections"; +import { + faCalendarDays, + faFileImage, + faFilePdf, +} from "@fortawesome/free-regular-svg-icons"; +import isValidUrl from "@/lib/shared/isValidUrl"; +import unescapeString from "@/lib/client/unescapeString"; +import useLocalSettingsStore from "@/store/localSettings"; +import Modal from "../Modal"; + +type Props = { + link: LinkIncludingShortenedCollectionAndTags; + onClose: Function; +}; + +export default function LinkDetails({ link, onClose }: Props) { + const { + settings: { theme }, + } = useLocalSettingsStore(); + + const [imageError, setImageError] = useState(false); + const formattedDate = new Date(link.createdAt as string).toLocaleString( + "en-US", + { + year: "numeric", + month: "short", + day: "numeric", + } + ); + + const { collections } = useCollectionStore(); + + 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]); + + const [colorPalette, setColorPalette] = useState(); + + const colorThief = new ColorThief(); + + const url = link.url && isValidUrl(link.url) ? new URL(link.url) : undefined; + + const handleDownload = (format: "png" | "pdf") => { + const path = `/api/v1/archives/${link.collection.id}/${link.id}.${format}`; + fetch(path) + .then((response) => { + if (response.ok) { + // Create a temporary link and click it to trigger the download + const link = document.createElement("a"); + link.href = path; + link.download = format === "pdf" ? "PDF" : "Screenshot"; + link.click(); + } else { + console.error("Failed to download file"); + } + }) + .catch((error) => { + console.error("Error:", error); + }); + }; + + return ( + +
+ {!imageError && url && ( + { + try { + const color = colorThief.getPalette( + e.target as HTMLImageElement, + 4 + ); + + setColorPalette(color); + } catch (err) { + console.log(err); + } + }} + onError={(e) => { + setImageError(true); + }} + /> + )} +
+

+ {unescapeString(link.name)} +

+ + {url ? url.host : link.url} + +
+
+
+ + +

+ {collection?.name} +

+ + {link.tags.map((e, i) => ( + +

+ {e.name} +

+ + ))} +
+ {link.description && ( + <> +
+ {unescapeString(link.description)} +
+ + )} + +
+
+ +

Archived Formats:

+
+
+ +

{formattedDate}

+
+
+
+
+
+
+ +
+ +

Screenshot

+
+ +
+ + + + +
handleDownload("png")} + className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md" + > + +
+
+
+ +
+
+
+ +
+ +

PDF

+
+ +
+ + + + +
handleDownload("pdf")} + className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md" + > + +
+
+
+
+
+ ); +} diff --git a/components/ModalContent/NewLinkModal.tsx b/components/ModalContent/NewLinkModal.tsx index afca87d..7a0087c 100644 --- a/components/ModalContent/NewLinkModal.tsx +++ b/components/ModalContent/NewLinkModal.tsx @@ -64,7 +64,6 @@ export default function NewLinkModal({ onClose }: Props) { }; useEffect(() => { - setOptionsExpanded(false); if (router.query.id) { const currentCollection = collections.find( (e) => e.id == Number(router.query.id) diff --git a/components/ModalContent/PreservedFormatsModal.tsx b/components/ModalContent/PreservedFormatsModal.tsx new file mode 100644 index 0000000..951e9ca --- /dev/null +++ b/components/ModalContent/PreservedFormatsModal.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useState } from "react"; +import { Toaster } from "react-hot-toast"; +import CollectionSelection from "@/components/InputSelect/CollectionSelection"; +import TagSelection from "@/components/InputSelect/TagSelection"; +import TextInput from "@/components/TextInput"; +import unescapeString from "@/lib/client/unescapeString"; +import useLinkStore from "@/store/links"; +import { + ArchivedFormat, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; +import toast from "react-hot-toast"; +import Link from "next/link"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faArrowUpRightFromSquare, + faCloudArrowDown, + faLink, + faTrashCan, + faUpRightFromSquare, +} from "@fortawesome/free-solid-svg-icons"; +import Modal from "../Modal"; +import { faFileImage, faFilePdf } from "@fortawesome/free-regular-svg-icons"; +import { useRouter } from "next/router"; +import { useSession } from "next-auth/react"; + +type Props = { + onClose: Function; + activeLink: LinkIncludingShortenedCollectionAndTags; +}; + +export default function PreservedFormatsModal({ onClose, activeLink }: Props) { + const session = useSession(); + const { links, getLink } = useLinkStore(); + + const [link, setLink] = + useState(activeLink); + + const router = useRouter(); + + useEffect(() => { + let isPublicRoute = router.pathname.startsWith("/public") + ? true + : undefined; + + (async () => { + const data = await getLink(link.id as number, isPublicRoute); + setLink( + (data as any).response as LinkIncludingShortenedCollectionAndTags + ); + })(); + + let interval: NodeJS.Timer | undefined; + if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") { + interval = setInterval(async () => { + const data = await getLink(link.id as number, isPublicRoute); + setLink( + (data as any).response as LinkIncludingShortenedCollectionAndTags + ); + }, 5000); + } else { + if (interval) { + clearInterval(interval); + } + } + + return () => { + if (interval) { + clearInterval(interval); + } + }; + }, [link?.screenshotPath, link?.pdfPath, link?.readabilityPath]); + + const updateArchive = async () => { + const load = toast.loading("Sending request..."); + + const response = await fetch(`/api/v1/links/${link?.id}/archive`, { + method: "PUT", + }); + + const data = await response.json(); + + toast.dismiss(load); + + if (response.ok) { + toast.success(`Link is being archived...`); + getLink(link?.id as number); + } else toast.error(data.response); + }; + + const handleDownload = (format: ArchivedFormat) => { + const path = `/api/v1/archives/${link?.id}?format=${format}`; + fetch(path) + .then((response) => { + if (response.ok) { + // Create a temporary link and click it to trigger the download + const link = document.createElement("a"); + link.href = path; + link.download = format === ArchivedFormat.png ? "Screenshot" : "PDF"; + link.click(); + } else { + console.error("Failed to download file"); + } + }) + .catch((error) => { + console.error("Error:", error); + }); + }; + + return ( + +

Preserved Formats

+ +
+ +
+ {link?.screenshotPath && link?.screenshotPath !== "pending" ? ( +
+
+
+ +
+ +

Screenshot

+
+ +
+
handleDownload(ArchivedFormat.png)} + className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" + > + +
+ + + + +
+
+ ) : undefined} + + {link?.pdfPath && link.pdfPath !== "pending" ? ( +
+
+
+ +
+ +

PDF

+
+ +
+
handleDownload(ArchivedFormat.pdf)} + className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" + > + +
+ + + + +
+
+ ) : undefined} + +
+ {link?.collection.ownerId === session.data?.user.id ? ( +
updateArchive()} + > +
+

Update Preserved Formats

+

(Refresh Link)

+
+
+ ) : undefined} + +

+ View Latest Snapshot on archive.org +

+ + +
+
+
+ ); +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index d61145a..a493fa9 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -90,7 +90,7 @@ export default function Navbar() { New Link -
  • + {/*
  • { (document?.activeElement as HTMLElement)?.blur(); @@ -101,7 +101,7 @@ export default function Navbar() { > Upload File
    -
  • + */}
  • { diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index bea8c9d..76ec12d 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -21,7 +21,7 @@ import { } from "@fortawesome/free-brands-svg-icons"; export default function SettingsSidebar({ className }: { className?: string }) { - const LINKWARDEN_VERSION = "v2.3.0"; + const LINKWARDEN_VERSION = "v2.4.0"; const { collections } = useCollectionStore(); diff --git a/layouts/LinkLayout.tsx b/layouts/LinkLayout.tsx index 0dc5027..97baf16 100644 --- a/layouts/LinkLayout.tsx +++ b/layouts/LinkLayout.tsx @@ -19,6 +19,8 @@ import { } from "@/types/global"; import { useSession } from "next-auth/react"; import useCollectionStore from "@/store/collections"; +import EditLinkModal from "@/components/ModalContent/EditLinkModal"; +import Link from "next/link"; interface Props { children: ReactNode; @@ -73,17 +75,17 @@ export default function LinkLayout({ children }: Props) { setLinkCollection(collections.find((e) => e.id === link?.collection?.id)); }, [link, collections]); + const [editLinkModal, setEditLinkModal] = useState(false); + return ( <> - -
    {/*
    */}
    -
    +
    {/*
    */} -
    { - if (router.pathname.startsWith("/public")) { - router.push( - `/public/collections/${ + - Back{" "} - - to{" "} - - {router.pathname.startsWith("/public") - ? linkCollection?.name || link?.collection?.name - : "Dashboard"} - + + {router.pathname.startsWith("/public") + ? linkCollection?.name || link?.collection?.name + : "Dashboard"} -
    + -
    +
    {link?.collection?.ownerId === userId || linkCollection?.members.some( (e) => e.userId === userId && e.canUpdate @@ -125,20 +119,13 @@ export default function LinkLayout({ children }: Props) {
    { - link - ? setModal({ - modal: "LINK", - state: true, - active: link, - method: "UPDATE", - }) - : undefined; + setEditLinkModal(true); }} - className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} + className={`btn btn-ghost btn-square btn-sm`} >
    ) : undefined} @@ -201,6 +188,12 @@ export default function LinkLayout({ children }: Props) {
    ) : null}
    + {link && editLinkModal ? ( + setEditLinkModal(false)} + activeLink={link} + /> + ) : undefined}
    ); diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index 6cd4d55..3d28a3f 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/api/db"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; -import getTitle from "@/lib/api/getTitle"; +import getTitle from "@/lib/shared/getTitle"; import urlHandler from "@/lib/api/urlHandler"; import { UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; diff --git a/lib/api/getTitle.ts b/lib/shared/getTitle.ts similarity index 100% rename from lib/api/getTitle.ts rename to lib/shared/getTitle.ts diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts index 0b8992e..8686c15 100644 --- a/pages/api/v1/archives/[linkId].ts +++ b/pages/api/v1/archives/[linkId].ts @@ -62,82 +62,83 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { res.setHeader("Content-Type", contentType).status(status as number); return res.send(file); - } else if (req.method === "POST") { - const user = await verifyUser({ req, res }); - if (!user) return; - - const collectionPermissions = await getPermission({ - userId: user.id, - linkId, - }); - - const memberHasAccess = collectionPermissions?.members.some( - (e: UsersAndCollections) => e.userId === user.id && e.canCreate - ); - - if (!(collectionPermissions?.ownerId === user.id || memberHasAccess)) - return { response: "Collection is not accessible.", status: 401 }; - - // await uploadHandler(linkId, ) - - const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_UPLOAD_SIZE); - - const form = formidable({ - maxFields: 1, - maxFiles: 1, - maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576, - }); - - form.parse(req, async (err, fields, files) => { - const allowedMIMETypes = [ - "application/pdf", - "image/png", - "image/jpg", - "image/jpeg", - ]; - - if ( - err || - !files.file || - !files.file[0] || - !allowedMIMETypes.includes(files.file[0].mimetype || "") - ) { - // Handle parsing error - return res.status(500).json({ - response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`, - }); - } else { - const fileBuffer = fs.readFileSync(files.file[0].filepath); - - const linkStillExists = await prisma.link.findUnique({ - where: { id: linkId }, - }); - - if (linkStillExists) { - await createFile({ - filePath: `archives/${collectionPermissions?.id}/${ - linkId + suffix - }`, - data: fileBuffer, - }); - - await prisma.link.update({ - where: { id: linkId }, - data: { - screenshotPath: `archives/${collectionPermissions?.id}/${ - linkId + suffix - }`, - lastPreserved: new Date().toISOString(), - }, - }); - } - - fs.unlinkSync(files.file[0].filepath); - } - - return res.status(200).json({ - response: files, - }); - }); } + // else if (req.method === "POST") { + // const user = await verifyUser({ req, res }); + // if (!user) return; + + // const collectionPermissions = await getPermission({ + // userId: user.id, + // linkId, + // }); + + // const memberHasAccess = collectionPermissions?.members.some( + // (e: UsersAndCollections) => e.userId === user.id && e.canCreate + // ); + + // if (!(collectionPermissions?.ownerId === user.id || memberHasAccess)) + // return { response: "Collection is not accessible.", status: 401 }; + + // // await uploadHandler(linkId, ) + + // const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_UPLOAD_SIZE); + + // const form = formidable({ + // maxFields: 1, + // maxFiles: 1, + // maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576, + // }); + + // form.parse(req, async (err, fields, files) => { + // const allowedMIMETypes = [ + // "application/pdf", + // "image/png", + // "image/jpg", + // "image/jpeg", + // ]; + + // if ( + // err || + // !files.file || + // !files.file[0] || + // !allowedMIMETypes.includes(files.file[0].mimetype || "") + // ) { + // // Handle parsing error + // return res.status(500).json({ + // response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`, + // }); + // } else { + // const fileBuffer = fs.readFileSync(files.file[0].filepath); + + // const linkStillExists = await prisma.link.findUnique({ + // where: { id: linkId }, + // }); + + // if (linkStillExists) { + // await createFile({ + // filePath: `archives/${collectionPermissions?.id}/${ + // linkId + suffix + // }`, + // data: fileBuffer, + // }); + + // await prisma.link.update({ + // where: { id: linkId }, + // data: { + // screenshotPath: `archives/${collectionPermissions?.id}/${ + // linkId + suffix + // }`, + // lastPreserved: new Date().toISOString(), + // }, + // }); + // } + + // fs.unlinkSync(files.file[0].filepath); + // } + + // return res.status(200).json({ + // response: files, + // }); + // }); + // } } diff --git a/pages/links/[id].tsx b/pages/links/[id].tsx index 0a991e1..5d98a76 100644 --- a/pages/links/[id].tsx +++ b/pages/links/[id].tsx @@ -13,7 +13,11 @@ import unescapeString from "@/lib/client/unescapeString"; import isValidUrl from "@/lib/shared/isValidUrl"; import DOMPurify from "dompurify"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons"; +import { + faBoxesStacked, + faFolder, + faLink, +} from "@fortawesome/free-solid-svg-icons"; import useModalStore from "@/store/modals"; import { useSession } from "next-auth/react"; import useLocalSettingsStore from "@/store/localSettings"; @@ -118,19 +122,19 @@ export default function Index() { if (colorPalette && banner && bannerInner) { if (colorPalette[0] && colorPalette[1]) { - banner.style.background = `linear-gradient(to right, ${rgbToHex( + banner.style.background = `linear-gradient(to bottom, ${rgbToHex( colorPalette[0][0], colorPalette[0][1], colorPalette[0][2] - )}30, ${rgbToHex( + )}20, ${rgbToHex( colorPalette[1][0], colorPalette[1][1], colorPalette[1][2] - )}30)`; + )}20)`; } if (colorPalette[2] && colorPalette[3]) { - bannerInner.style.background = `linear-gradient(to left, ${rgbToHex( + bannerInner.style.background = `linear-gradient(to bottom, ${rgbToHex( colorPalette[2][0], colorPalette[2][1], colorPalette[2][2] @@ -145,19 +149,15 @@ export default function Index() { return ( -
    +