diff --git a/.env.sample b/.env.sample index 39e1e43..1277ade 100644 --- a/.env.sample +++ b/.env.sample @@ -9,6 +9,7 @@ STORAGE_FOLDER= AUTOSCROLL_TIMEOUT= NEXT_PUBLIC_DISABLE_REGISTRATION= IMPORT_SIZE_LIMIT= +RE_ARCHIVE_LIMIT= # AWS S3 Settings SPACES_KEY= diff --git a/components/Dropdown.tsx b/components/Dropdown.tsx index 19e2778..5ec8198 100644 --- a/components/Dropdown.tsx +++ b/components/Dropdown.tsx @@ -21,7 +21,6 @@ type Props = { items: MenuItem[]; points?: { x: number; y: number }; style?: React.CSSProperties; - width?: number; // in rem }; export default function Dropdown({ @@ -29,7 +28,6 @@ export default function Dropdown({ onClickOutside, className, items, - width, }: Props) { const [pos, setPos] = useState<{ x: number; y: number }>(); const [dropdownHeight, setDropdownHeight] = useState(); @@ -60,7 +58,7 @@ export default function Dropdown({ setPos({ x: finalX, y: finalY }); } - }, [points, width, dropdownHeight]); + }, [points, dropdownHeight]); return ( (!points || pos) && ( diff --git a/components/LinkCard.tsx b/components/LinkCard.tsx index 35f60e4..d7c28eb 100644 --- a/components/LinkCard.tsx +++ b/components/LinkCard.tsx @@ -254,7 +254,7 @@ export default function LinkCard({ link, count, className }: Props) { : undefined, permissions === true ? { - name: "Update Archive", + name: "Refresh Formats", onClick: updateArchive, } : undefined, diff --git a/components/Modal/Link/PreservedFormats.tsx b/components/Modal/Link/PreservedFormats.tsx index 5fe643f..1a6712b 100644 --- a/components/Modal/Link/PreservedFormats.tsx +++ b/components/Modal/Link/PreservedFormats.tsx @@ -55,7 +55,7 @@ export default function PreservedFormats() { if (response.ok) { toast.success(`Link is being archived...`); getLink(link?.id as number); - } else toast.error(data); + } else toast.error(data.response); }; const handleDownload = (format: "png" | "pdf") => { @@ -152,11 +152,18 @@ export default function PreservedFormats() {
{link?.collection.ownerId === session.data?.user.id ? (
updateArchive()} >

Update Preserved Formats

-

(re-fetch)

+

(Refresh Formats)

) : undefined} - {/* {method === "CREATE" && ( -

- New Link -

- )} */} + {method === "CREATE" ? ( + <> +

+ Create a New Link +

+ + + ) : undefined} + {activeLink && method === "UPDATE" ? ( <>

Edit Link

@@ -46,19 +50,10 @@ export default function LinkModal({ ) : undefined} - {method === "CREATE" ? ( + {method === "FORMATS" ? ( <>

- Create a New Link -

- - - ) : undefined} - - {activeLink && method === "FORMATS" ? ( - <> -

- Manage Preserved Formats + Preserved Formats

diff --git a/layouts/LinkLayout.tsx b/layouts/LinkLayout.tsx index b3c61c1..4850802 100644 --- a/layouts/LinkLayout.tsx +++ b/layouts/LinkLayout.tsx @@ -8,6 +8,18 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import Link from "next/link"; import useWindowDimensions from "@/hooks/useWindowDimensions"; +import { + faPen, + faBoxesStacked, + faTrashCan, +} from "@fortawesome/free-solid-svg-icons"; +import useLinkStore from "@/store/links"; +import { + CollectionIncludingMembersAndLinkCount, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; +import { useSession } from "next-auth/react"; +import useCollectionStore from "@/store/collections"; interface Props { children: ReactNode; @@ -40,6 +52,28 @@ export default function LinkLayout({ children }: Props) { setSidebar(!sidebar); }; + const session = useSession(); + const userId = session.data?.user.id; + + const { setModal } = useModalStore(); + + const { links, removeLink } = useLinkStore(); + const { collections } = useCollectionStore(); + + const [linkCollection, setLinkCollection] = + useState(); + + const [link, setLink] = useState(); + + useEffect(() => { + if (links) setLink(links.find((e) => e.id === Number(router.query.id))); + }, [links]); + + useEffect(() => { + if (link) + setLinkCollection(collections.find((e) => e.id === link?.collection.id)); + }, [link]); + return ( <> @@ -50,21 +84,91 @@ export default function LinkLayout({ children }: Props) {
-
-
+ {/*
-
+
*/}
router.back()} - className="inline-flex gap-1 lg:hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700 lg:hover:bg-transparent lg:dark:hover:bg-transparent" + className="inline-flex gap-1 hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100" > Back
+ +
+
+ {link?.collection.ownerId === userId || + linkCollection?.members.some( + (e) => e.userId === userId && e.canUpdate + ) ? ( +
{ + link + ? setModal({ + modal: "LINK", + state: true, + active: link, + method: "UPDATE", + }) + : undefined; + }} + className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} + > + +
+ ) : undefined} + +
{ + link + ? setModal({ + modal: "LINK", + state: true, + active: link, + method: "FORMATS", + }) + : undefined; + }} + title="Preserved Formats" + className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} + > + +
+ + {link?.collection.ownerId === userId || + linkCollection?.members.some( + (e) => e.userId === userId && e.canDelete + ) ? ( +
{ + if (link?.id) { + removeLink(link.id); + router.back(); + } + }} + title="Delete" + className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} + > + +
+ ) : undefined} +
+
{children} diff --git a/lib/api/archive.ts b/lib/api/archive.ts index f5506fb..25f5514 100644 --- a/lib/api/archive.ts +++ b/lib/api/archive.ts @@ -11,36 +11,15 @@ export default async function archive( url: string, userId: number ) { - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - }); - - // const checkExistingLink = await prisma.link.findFirst({ - // where: { - // id: linkId, - // OR: [ - // { - // screenshotPath: "pending", - // }, - // { - // pdfPath: "pending", - // }, - // ], - // }, - // }); - - // if (checkExistingLink) return "A request has already been made."; + const user = await prisma.user.findUnique({ where: { id: userId } }); const targetLink = await prisma.link.update({ - where: { - id: linkId, - }, + where: { id: linkId }, data: { screenshotPath: user?.archiveAsScreenshot ? "pending" : null, pdfPath: user?.archiveAsPDF ? "pending" : null, readabilityPath: "pending", + lastPreserved: new Date().toISOString(), }, }); @@ -56,7 +35,6 @@ export default async function archive( try { await page.goto(url, { waitUntil: "domcontentloaded" }); - await page.goto(url); const content = await page.content(); // Readability @@ -64,46 +42,35 @@ export default async function archive( const window = new JSDOM("").window; const purify = DOMPurify(window); const cleanedUpContent = purify.sanitize(content); - - const dom = new JSDOM(cleanedUpContent, { - url: url, - }); - + const dom = new JSDOM(cleanedUpContent, { url: url }); const article = new Readability(dom.window.document).parse(); - await prisma.link.update({ - where: { - id: linkId, - }, - data: { - readabilityPath: `archives/${targetLink.collectionId}/${linkId}_readability.json`, - }, - }); - await createFile({ data: JSON.stringify(article), filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`, }); - // Screenshot/PDF - - let faulty = true; - await page - .evaluate(autoScroll, Number(process.env.AUTOSCROLL_TIMEOUT) || 30) - .catch((e) => (faulty = false)); - - const linkExists = await prisma.link.findUnique({ - where: { - id: linkId, + await prisma.link.update({ + where: { id: linkId }, + data: { + readabilityPath: `archives/${targetLink.collectionId}/${linkId}_readability.json`, }, }); - if (linkExists && faulty) { - if (user.archiveAsScreenshot) { - const screenshot = await page.screenshot({ - fullPage: true, - }); + // Screenshot/PDF + let faulty = false; + await page + .evaluate(autoScroll, Number(process.env.AUTOSCROLL_TIMEOUT) || 30) + .catch((e) => (faulty = true)); + + const linkExists = await prisma.link.findUnique({ + where: { id: linkId }, + }); + + if (linkExists && !faulty) { + if (user.archiveAsScreenshot) { + const screenshot = await page.screenshot({ fullPage: true }); await createFile({ data: screenshot, filePath: `archives/${linkExists.collectionId}/${linkId}.png`, @@ -124,10 +91,8 @@ export default async function archive( }); } - const updateLink = await prisma.link.update({ - where: { - id: linkId, - }, + await prisma.link.update({ + where: { id: linkId }, data: { screenshotPath: user.archiveAsScreenshot ? `archives/${linkExists.collectionId}/${linkId}.png` @@ -139,21 +104,18 @@ export default async function archive( }); } else if (faulty) { await prisma.link.update({ - where: { - id: linkId, - }, + where: { id: linkId }, data: { screenshotPath: null, pdfPath: null, }, }); } - - await browser.close(); } catch (err) { console.log(err); + throw err; + } finally { await browser.close(); - return err; } } } @@ -161,11 +123,7 @@ export default async function archive( const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { - reject( - new Error( - `Auto scroll took too long (more than ${AUTOSCROLL_TIMEOUT} seconds).` - ) - ); + reject(new Error(`Webpage was too long to be archived.`)); }, AUTOSCROLL_TIMEOUT * 1000); }); diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index 5830040..a147814 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -56,6 +56,7 @@ export default async function postLink( url: link.url, name: link.name, description, + readabilityPath: "pending", collection: { connectOrCreate: { where: { diff --git a/pages/api/v1/links/[id]/archive/index.ts b/pages/api/v1/links/[id]/archive/index.ts index 5bc4c2c..c1c38b0 100644 --- a/pages/api/v1/links/[id]/archive/index.ts +++ b/pages/api/v1/links/[id]/archive/index.ts @@ -4,6 +4,8 @@ import { authOptions } from "@/pages/api/v1/auth/[...nextauth]"; import archive from "@/lib/api/archive"; import { prisma } from "@/lib/api/db"; +const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; + export default async function links(req: NextApiRequest, res: NextApiResponse) { const session = await getServerSession(req, res, authOptions); @@ -33,6 +35,20 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) { }); if (req.method === "PUT") { + if ( + link?.lastPreserved && + getTimezoneDifferenceInMinutes(new Date(), link?.lastPreserved) < + RE_ARCHIVE_LIMIT + ) + return res.status(400).json({ + response: `This link is currently being saved or has already been preserved. Please retry in ${ + RE_ARCHIVE_LIMIT - + Math.floor( + getTimezoneDifferenceInMinutes(new Date(), link?.lastPreserved) + ) + } minutes or create a new one.`, + }); + archive(link.id, link.url, session.user.id); return res.status(200).json({ response: "Link is being archived.", @@ -42,3 +58,14 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) { // Later? // else if (req.method === "DELETE") {} } + +const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => { + const date1 = new Date(future); + const date2 = new Date(past); + + const diffInMilliseconds = Math.abs(date1.getTime() - date2.getTime()); + + const diffInMinutes = diffInMilliseconds / (1000 * 60); + + return diffInMinutes; +}; diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index 77b0334..ea09742 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -220,7 +220,7 @@ export default function Index() { if (target.id !== "expand-dropdown") setExpandDropdown(false); }} - className="absolute top-8 right-0 z-10 w-40" + className="absolute top-8 right-0 z-10 w-44" /> ) : null}
diff --git a/pages/links/[id].tsx b/pages/links/[id].tsx index 8b33e10..dfc3388 100644 --- a/pages/links/[id].tsx +++ b/pages/links/[id].tsx @@ -76,7 +76,11 @@ export default function Index() { useEffect(() => { let interval: NodeJS.Timer | undefined; - if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") { + if ( + link?.screenshotPath === "pending" || + link?.pdfPath === "pending" || + link?.readabilityPath === "pending" + ) { interval = setInterval(() => getLink(link.id as number), 5000); } else { if (interval) { @@ -190,6 +194,7 @@ export default function Index() {

@@ -240,46 +245,52 @@ export default function Index() {
- {link && - link?.readabilityPath && - link?.readabilityPath !== "pending" ? ( + {link?.readabilityPath?.startsWith("archives") ? (
) : (
-

- There is no reader view for this webpage -

-

- {link?.collection.ownerId === userId - ? "You can update (refetch) the preserved formats by managing them below" - : "The collections owners can refetch the preserved formats"} -

- {link?.collection.ownerId === userId ? ( -
- link - ? setModal({ - modal: "LINK", - state: true, - active: link, - method: "FORMATS", - }) - : undefined - } - className="mt-4 flex gap-2 w-fit mx-auto relative items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-md dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100" - > - -

Manage preserved formats

-
- ) : undefined} + {link?.readabilityPath === "pending" ? ( +

+ Generating readable format, please wait... +

+ ) : ( + <> +

+ There is no reader view for this webpage +

+

+ {link?.collection.ownerId === userId + ? "You can update (refetch) the preserved formats by managing them below" + : "The collections owners can refetch the preserved formats"} +

+ {link?.collection.ownerId === userId ? ( +
+ link + ? setModal({ + modal: "LINK", + state: true, + active: link, + method: "FORMATS", + }) + : undefined + } + className="mt-4 flex gap-2 w-fit mx-auto relative items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-md dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100" + > + +

Manage preserved formats

+
+ ) : undefined} + + )}
)}
diff --git a/prisma/migrations/20231031100017_add_last_preserved_field/migration.sql b/prisma/migrations/20231031100017_add_last_preserved_field/migration.sql new file mode 100644 index 0000000..912cad0 --- /dev/null +++ b/prisma/migrations/20231031100017_add_last_preserved_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Link" ADD COLUMN "lastPreserved" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 638a12f..a6bd6bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,6 +106,8 @@ model Link { screenshotPath String? pdfPath String? readabilityPath String? + + lastPreserved DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @default(now()) diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index 985958f..f529759 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -9,6 +9,7 @@ declare global { STORAGE_FOLDER?: string; AUTOSCROLL_TIMEOUT?: string; IMPORT_SIZE_LIMIT?: string; + RE_ARCHIVE_LIMIT?: string; SPACES_KEY?: string; SPACES_SECRET?: string;