diff --git a/components/InputSelect/styles.ts b/components/InputSelect/styles.ts index b86698a..2e2be2b 100644 --- a/components/InputSelect/styles.ts +++ b/components/InputSelect/styles.ts @@ -36,10 +36,6 @@ export const styles: StylesConfig = { ...styles, cursor: "pointer", }), - clearIndicator: (styles) => ({ - ...styles, - visibility: "hidden", - }), placeholder: (styles) => ({ ...styles, borderColor: "black", diff --git a/components/LinkCard.tsx b/components/LinkCard.tsx index e972d45..35f60e4 100644 --- a/components/LinkCard.tsx +++ b/components/LinkCard.tsx @@ -21,6 +21,7 @@ import { toast } from "react-hot-toast"; import isValidUrl from "@/lib/client/isValidUrl"; import Link from "next/link"; import unescapeString from "@/lib/client/unescapeString"; +import { useRouter } from "next/router"; type Props = { link: LinkIncludingShortenedCollectionAndTags; @@ -38,6 +39,8 @@ type DropdownTrigger = export default function LinkCard({ link, count, className }: Props) { const { setModal } = useModalStore(); + const router = useRouter(); + const permissions = usePermissions(link.collection.id as number); const [expandDropdown, setExpandDropdown] = useState(false); @@ -159,16 +162,7 @@ export default function LinkCard({ link, count, className }: Props) { )}
{ - setModal({ - modal: "LINK", - state: true, - method: "UPDATE", - isOwnerOrMod: - permissions === true || (permissions?.canUpdate as boolean), - active: link, - }); - }} + onClick={() => router.push("/links/" + link.id)} className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5" > {url && ( @@ -252,10 +246,7 @@ export default function LinkCard({ link, count, className }: Props) { modal: "LINK", state: true, method: "UPDATE", - isOwnerOrMod: - permissions === true || permissions?.canUpdate, active: link, - defaultIndex: 1, }); setExpandDropdown(false); }, diff --git a/components/LinkSidebar.tsx b/components/LinkSidebar.tsx new file mode 100644 index 0000000..d8c553f --- /dev/null +++ b/components/LinkSidebar.tsx @@ -0,0 +1,138 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faPen, + faBoxesStacked, + faTrashCan, +} from "@fortawesome/free-solid-svg-icons"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import useModalStore from "@/store/modals"; +import useLinkStore from "@/store/links"; +import { + CollectionIncludingMembersAndLinkCount, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; +import { useSession } from "next-auth/react"; +import useCollectionStore from "@/store/collections"; + +type Props = { + className?: string; + onClick?: Function; +}; + +export default function SettingsSidebar({ className, onClick }: Props) { + 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(); + + const router = useRouter(); + + 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 ( +
+
+ {link?.collection.ownerId === userId || + linkCollection?.members.some( + (e) => e.userId === userId && e.canUpdate + ) ? ( +
{ + link + ? setModal({ + modal: "LINK", + state: true, + active: link, + method: "UPDATE", + }) + : undefined; + onClick && onClick(); + }} + className={`hover:opacity-50 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} + > + + +

+ Edit +

+
+ ) : undefined} + +
{ + link + ? setModal({ + modal: "LINK", + state: true, + active: link, + method: "FORMATS", + }) + : undefined; + onClick && onClick(); + }} + title="Preserved Formats" + className={`hover:opacity-50 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} + > + + +

+ Preserved Formats +

+
+ + {link?.collection.ownerId === userId || + linkCollection?.members.some( + (e) => e.userId === userId && e.canDelete + ) ? ( +
{ + if (link?.id) { + removeLink(link.id); + router.back(); + onClick && onClick(); + } + }} + title="Delete" + className={`hover:opacity-50 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} + > + + +

+ Delete +

+
+ ) : undefined} +
+
+ ); +} diff --git a/components/Modal/Link/AddOrEditLink.tsx b/components/Modal/Link/AddOrEditLink.tsx index 201b3d0..eab727b 100644 --- a/components/Modal/Link/AddOrEditLink.tsx +++ b/components/Modal/Link/AddOrEditLink.tsx @@ -4,8 +4,7 @@ import TagSelection from "@/components/InputSelect/TagSelection"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import useLinkStore from "@/store/links"; -import { faPlus } from "@fortawesome/free-solid-svg-icons"; -import RequiredBadge from "../../RequiredBadge"; +import { faLink, faPlus } from "@fortawesome/free-solid-svg-icons"; import { useSession } from "next-auth/react"; import useCollectionStore from "@/store/collections"; import { useRouter } from "next/router"; @@ -14,6 +13,7 @@ import { toast } from "react-hot-toast"; import Link from "next/link"; import TextInput from "@/components/TextInput"; import unescapeString from "@/lib/client/unescapeString"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; type Props = | { @@ -48,6 +48,7 @@ export default function AddOrEditLink({ tags: [], screenshotPath: "", pdfPath: "", + readabilityPath: "", collection: { name: "", ownerId: data?.user.id as number, @@ -135,23 +136,22 @@ export default function AddOrEditLink({ return (
{method === "UPDATE" ? ( -

- Editing:{" "} - + + {link.url} -

+
) : null} {method === "CREATE" ? (
-

+

Address (URL) -

-
+ {/*
*/}

Name

diff --git a/components/Modal/Link/PreservedFormats.tsx b/components/Modal/Link/PreservedFormats.tsx new file mode 100644 index 0000000..c6b7e18 --- /dev/null +++ b/components/Modal/Link/PreservedFormats.tsx @@ -0,0 +1,181 @@ +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faArrowUpRightFromSquare, + faCloudArrowDown, +} from "@fortawesome/free-solid-svg-icons"; +import { faFileImage, faFilePdf } from "@fortawesome/free-regular-svg-icons"; +import useLinkStore from "@/store/links"; +import { toast } from "react-hot-toast"; +import { useRouter } from "next/router"; +import { useSession } from "next-auth/react"; + +export default function PreservedFormats() { + const session = useSession(); + const { links, getLink } = useLinkStore(); + + const [link, setLink] = useState(); + + const router = useRouter(); + + useEffect(() => { + if (links) setLink(links.find((e) => e.id === Number(router.query.id))); + }, [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, 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); + }; + + 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 ( +
+ {link?.screenshotPath && link?.screenshotPath !== "pending" ? ( +
+
+
+ +
+ +

Screenshot

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

PDF

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

Update Preserved Formats

+

(re-fetch)

+
+ ) : undefined} + + +

+ View Latest Snapshot on archive.org +

+ +
+
+ ); +} diff --git a/components/Modal/Link/index.tsx b/components/Modal/Link/index.tsx index 2ebf08d..ff48950 100644 --- a/components/Modal/Link/index.tsx +++ b/components/Modal/Link/index.tsx @@ -1,94 +1,68 @@ -import { Tab } from "@headlessui/react"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import AddOrEditLink from "./AddOrEditLink"; -import LinkDetails from "./LinkDetails"; +import PreservedFormats from "./PreservedFormats"; type Props = | { toggleLinkModal: Function; method: "CREATE"; - isOwnerOrMod?: boolean; activeLink?: LinkIncludingShortenedCollectionAndTags; - defaultIndex?: number; className?: string; } | { toggleLinkModal: Function; method: "UPDATE"; - isOwnerOrMod: boolean; activeLink: LinkIncludingShortenedCollectionAndTags; - defaultIndex?: number; + className?: string; + } + | { + toggleLinkModal: Function; + method: "FORMATS"; + activeLink: LinkIncludingShortenedCollectionAndTags; className?: string; }; export default function LinkModal({ className, - defaultIndex, toggleLinkModal, - isOwnerOrMod, activeLink, method, }: Props) { return (
- - {method === "CREATE" && ( -

- New Link -

- )} - - {method === "UPDATE" && isOwnerOrMod && ( - <> - - selected - ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 duration-100 rounded-md outline-none" - : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 rounded-md duration-100 outline-none" - } - > - Link Details - - - selected - ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 duration-100 rounded-md outline-none" - : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 rounded-md duration-100 outline-none" - } - > - Edit Link - - - )} - - - {activeLink && method === "UPDATE" && ( - - - - )} + {/* {method === "CREATE" && ( +

+ New Link +

+ )} */} + {activeLink && method === "UPDATE" ? ( + <> +

Edit Link

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

+ Create a New Link +

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

+ Manage Preserved Formats +

+ + + ) : undefined}
); } diff --git a/components/ModalManagement.tsx b/components/ModalManagement.tsx index aa7790d..0a3d6a8 100644 --- a/components/ModalManagement.tsx +++ b/components/ModalManagement.tsx @@ -27,8 +27,6 @@ export default function ModalManagement() { diff --git a/layouts/LinkLayout.tsx b/layouts/LinkLayout.tsx new file mode 100644 index 0000000..fdafab3 --- /dev/null +++ b/layouts/LinkLayout.tsx @@ -0,0 +1,88 @@ +import LinkSidebar from "@/components/LinkSidebar"; +import { ReactNode, useEffect, useState } from "react"; +import ModalManagement from "@/components/ModalManagement"; +import useModalStore from "@/store/modals"; +import { useRouter } from "next/router"; +import ClickAwayHandler from "@/components/ClickAwayHandler"; +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"; + +interface Props { + children: ReactNode; +} + +export default function LinkLayout({ children }: Props) { + const { modal } = useModalStore(); + + const router = useRouter(); + + useEffect(() => { + modal + ? (document.body.style.overflow = "hidden") + : (document.body.style.overflow = "auto"); + }, [modal]); + + const [sidebar, setSidebar] = useState(false); + + const { width } = useWindowDimensions(); + + useEffect(() => { + setSidebar(false); + }, [width]); + + useEffect(() => { + setSidebar(false); + }, [router]); + + const toggleSidebar = () => { + setSidebar(!sidebar); + }; + + return ( + <> + + +
+
+ +
+ +
+
+
+ +
+ +
router.back()} + className="inline-flex gap-1 lg:hover:opacity-50 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" + > + + Back +
+
+ + {children} + + {sidebar ? ( +
+ +
+ setSidebar(false)} /> +
+
+
+ ) : null} +
+
+ + ); +} diff --git a/lib/api/archive.ts b/lib/api/archive.ts index 635703e..f5506fb 100644 --- a/lib/api/archive.ts +++ b/lib/api/archive.ts @@ -85,8 +85,6 @@ export default async function archive( filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`, }); - console.log(JSON.parse(JSON.stringify(article))); - // Screenshot/PDF let faulty = true; diff --git a/lib/api/controllers/links/linkId/getLinkById.ts b/lib/api/controllers/links/linkId/getLinkById.ts index fb7d0c8..83a3ceb 100644 --- a/lib/api/controllers/links/linkId/getLinkById.ts +++ b/lib/api/controllers/links/linkId/getLinkById.ts @@ -16,7 +16,7 @@ export default async function getLinkById(userId: number, linkId: number) { | null; const memberHasAccess = collectionIsAccessible?.members.some( - (e: UsersAndCollections) => e.userId === userId && e.canUpdate + (e: UsersAndCollections) => e.userId === userId ); const isCollectionOwner = collectionIsAccessible?.ownerId === userId; diff --git a/pages/api/v1/links/[id]/archive/index.ts b/pages/api/v1/links/[id]/archive/index.ts index 29e5c66..5bc4c2c 100644 --- a/pages/api/v1/links/[id]/archive/index.ts +++ b/pages/api/v1/links/[id]/archive/index.ts @@ -5,7 +5,6 @@ import archive from "@/lib/api/archive"; import { prisma } from "@/lib/api/db"; export default async function links(req: NextApiRequest, res: NextApiResponse) { - console.log("hi"); const session = await getServerSession(req, res, authOptions); if (!session?.user?.id) { diff --git a/pages/links/[id].tsx b/pages/links/[id].tsx new file mode 100644 index 0000000..fef38e4 --- /dev/null +++ b/pages/links/[id].tsx @@ -0,0 +1,257 @@ +import LinkLayout from "@/layouts/LinkLayout"; +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import useLinkStore from "@/store/links"; +import { useRouter } from "next/router"; +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import Image from "next/image"; +import ColorThief, { RGBColor } from "colorthief"; +import { useTheme } from "next-themes"; +import unescapeString from "@/lib/client/unescapeString"; +import isValidUrl from "@/lib/client/isValidUrl"; +import DOMPurify from "dompurify"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBoxesStacked } from "@fortawesome/free-solid-svg-icons"; +import useModalStore from "@/store/modals"; +import { useSession } from "next-auth/react"; + +type LinkContent = { + title: string; + content: string; + textContent: string; + length: number; + excerpt: string; + byline: string; + dir: string; + siteName: string; + lang: string; +}; + +export default function Index() { + const { theme } = useTheme(); + const { links, getLink } = useLinkStore(); + const { setModal } = useModalStore(); + + const session = useSession(); + const userId = session.data?.user.id; + + const [link, setLink] = useState(); + const [linkContent, setLinkContent] = useState(); + const [imageError, setImageError] = useState(false); + const [colorPalette, setColorPalette] = useState(); + + const router = useRouter(); + + useEffect(() => { + const fetchLink = async () => { + if (router.query.id) { + await getLink(Number(router.query.id)); + } + }; + + fetchLink(); + }, []); + + useEffect(() => { + if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id))); + }, [links]); + + useEffect(() => { + const fetchLinkContent = async () => { + if ( + router.query.id && + link?.readabilityPath && + link?.readabilityPath !== "pending" + ) { + const response = await fetch(`/api/v1/${link?.readabilityPath}`); + + const data = await response?.json(); + + setLinkContent(data); + } + }; + + fetchLinkContent(); + }, [link]); + + 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, link?.readabilityPath]); + + const colorThief = new ColorThief(); + + const rgbToHex = (r: number, g: number, b: number): string => + "#" + + [r, g, b] + .map((x) => { + const hex = x.toString(16); + return hex.length === 1 ? "0" + hex : hex; + }) + .join(""); + + useEffect(() => { + const banner = document.getElementById("link-banner"); + const bannerInner = document.getElementById("link-banner-inner"); + + if (colorPalette && banner && bannerInner) { + if (colorPalette[0] && colorPalette[1]) { + banner.style.background = `linear-gradient(to right, ${rgbToHex( + colorPalette[0][0], + colorPalette[0][1], + colorPalette[0][2] + )}30, ${rgbToHex( + colorPalette[1][0], + colorPalette[1][1], + colorPalette[1][2] + )}30)`; + } + + if (colorPalette[2] && colorPalette[3]) { + bannerInner.style.background = `linear-gradient(to left, ${rgbToHex( + colorPalette[2][0], + colorPalette[2][1], + colorPalette[2][2] + )}30, ${rgbToHex( + colorPalette[3][0], + colorPalette[3][1], + colorPalette[3][2] + )})30`; + } + } + }, [colorPalette, theme]); + + return ( + +
+ + +
+ {link && + link?.readabilityPath && + link?.readabilityPath !== "pending" ? ( +
+ ) : ( +
+

+ 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/pages/links.tsx b/pages/links/index.tsx similarity index 100% rename from pages/links.tsx rename to pages/links/index.tsx diff --git a/store/links.ts b/store/links.ts index b5e14ff..9362742 100644 --- a/store/links.ts +++ b/store/links.ts @@ -72,11 +72,23 @@ const useLinkStore = create()((set) => ({ const data = await response.json(); if (response.ok) { - set((state) => ({ - links: state.links.map((e) => - e.id === data.response.id ? data.response : e - ), - })); + set((state) => { + const linkExists = state.links.some( + (link) => link.id === data.response.id + ); + + if (linkExists) { + return { + links: state.links.map((e) => + e.id === data.response.id ? data.response : e + ), + }; + } else { + return { + links: [...state.links, data.response], + }; + } + }); } return { ok: response.ok, data: data.response }; diff --git a/store/modals.ts b/store/modals.ts index 71c336f..71ecef0 100644 --- a/store/modals.ts +++ b/store/modals.ts @@ -9,17 +9,19 @@ type Modal = modal: "LINK"; state: boolean; method: "CREATE"; - isOwnerOrMod?: boolean; active?: LinkIncludingShortenedCollectionAndTags; - defaultIndex?: number; } | { modal: "LINK"; state: boolean; method: "UPDATE"; - isOwnerOrMod: boolean; active: LinkIncludingShortenedCollectionAndTags; - defaultIndex?: number; + } + | { + modal: "LINK"; + state: boolean; + method: "FORMATS"; + active: LinkIncludingShortenedCollectionAndTags; } | { modal: "COLLECTION"; diff --git a/styles/globals.css b/styles/globals.css index bf7e9a9..3088396 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -129,72 +129,9 @@ /* For the Link banner */ .link-banner { - /* box-shadow: inset 0px 10px 20px 20px #ffffff; */ - opacity: 25%; z-index: 0; -} -.link-banner .link-banner-inner { - /* box-shadow: inset 0px 10px 20px 20px #ffffff; */ - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - -webkit-mask: linear-gradient(#fff, transparent); - mask: linear-gradient(#fff, transparent); -} -.link-banner::after { - content: ""; - position: absolute; - z-index: 1; - bottom: 0; - left: 0; - pointer-events: none; - width: 100%; - height: 4rem; -} -.link-banner::before { - content: ""; - position: absolute; - z-index: 1; - top: 0; - left: 0; - pointer-events: none; - width: 100%; - height: 4rem; -} - -/* For light mode */ -.banner-light-mode .link-banner::after { - background-image: linear-gradient( - to bottom, - rgba(255, 255, 255, 0), - rgba(255, 255, 255, 1) 90% - ); -} -.banner-light-mode .link-banner::before { - background-image: linear-gradient( - to top, - rgba(255, 255, 255, 0), - rgba(255, 255, 255, 1) 90% - ); -} - -/* For dark mode */ -.banner-dark-mode .link-banner::after { - background-image: linear-gradient( - to bottom, - rgba(255, 255, 255, 0), - #171717 90% - ); -} -.banner-dark-mode .link-banner::before { - background-image: linear-gradient( - to top, - rgba(255, 255, 255, 0), - #171717 90% - ); + border-radius: 1rem; + height: fit-content; } /* Theme */ @@ -227,6 +164,12 @@ @apply dark:text-white; } } +.react-select__clear-indicator * { + display: none; + width: 0; + margin: 0; + padding: 0; +} .sky-shadow { box-shadow: 0px 0px 3px #0ea5e9; @@ -239,3 +182,7 @@ .primary-btn-gradient:hover { box-shadow: inset 0px -15px 10px #059bf8; } + +.line-break * { + overflow-x: auto; +}