fully added reader view support

This commit is contained in:
daniel31x13 2023-10-30 15:20:15 -04:00
parent ed91c4267b
commit fb61812356
16 changed files with 753 additions and 172 deletions

View File

@ -36,10 +36,6 @@ export const styles: StylesConfig = {
...styles, ...styles,
cursor: "pointer", cursor: "pointer",
}), }),
clearIndicator: (styles) => ({
...styles,
visibility: "hidden",
}),
placeholder: (styles) => ({ placeholder: (styles) => ({
...styles, ...styles,
borderColor: "black", borderColor: "black",

View File

@ -21,6 +21,7 @@ import { toast } from "react-hot-toast";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/client/isValidUrl";
import Link from "next/link"; import Link from "next/link";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { useRouter } from "next/router";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -38,6 +39,8 @@ type DropdownTrigger =
export default function LinkCard({ link, count, className }: Props) { export default function LinkCard({ link, count, className }: Props) {
const { setModal } = useModalStore(); const { setModal } = useModalStore();
const router = useRouter();
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false); const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
@ -159,16 +162,7 @@ export default function LinkCard({ link, count, className }: Props) {
)} )}
<div <div
onClick={() => { onClick={() => router.push("/links/" + link.id)}
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="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5"
> >
{url && ( {url && (
@ -252,10 +246,7 @@ export default function LinkCard({ link, count, className }: Props) {
modal: "LINK", modal: "LINK",
state: true, state: true,
method: "UPDATE", method: "UPDATE",
isOwnerOrMod:
permissions === true || permissions?.canUpdate,
active: link, active: link,
defaultIndex: 1,
}); });
setExpandDropdown(false); setExpandDropdown(false);
}, },

138
components/LinkSidebar.tsx Normal file
View File

@ -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<CollectionIncludingMembersAndLinkCount>();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
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 (
<div
className={`dark:bg-neutral-900 bg-white h-full lg:w-10 w-62 overflow-y-auto lg:p-0 p-5 border-solid border-white border dark:border-neutral-900 dark:lg:border-r-neutral-900 lg:border-r-white border-r-sky-100 dark:border-r-neutral-700 z-20 flex flex-col gap-5 lg:justify-center justify-start ${
className || ""
}`}
>
<div className="flex flex-col gap-5">
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate
) ? (
<div
title="Edit"
onClick={() => {
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`}
>
<FontAwesomeIcon
icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Edit
</p>
</div>
) : undefined}
<div
onClick={() => {
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`}
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Preserved Formats
</p>
</div>
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete
) ? (
<div
onClick={() => {
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`}
>
<FontAwesomeIcon
icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Delete
</p>
</div>
) : undefined}
</div>
</div>
);
}

View File

@ -4,8 +4,7 @@ import TagSelection from "@/components/InputSelect/TagSelection";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faLink, faPlus } from "@fortawesome/free-solid-svg-icons";
import RequiredBadge from "../../RequiredBadge";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -14,6 +13,7 @@ import { toast } from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = type Props =
| { | {
@ -48,6 +48,7 @@ export default function AddOrEditLink({
tags: [], tags: [],
screenshotPath: "", screenshotPath: "",
pdfPath: "", pdfPath: "",
readabilityPath: "",
collection: { collection: {
name: "", name: "",
ownerId: data?.user.id as number, ownerId: data?.user.id as number,
@ -135,23 +136,22 @@ export default function AddOrEditLink({
return ( return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80"> <div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{method === "UPDATE" ? ( {method === "UPDATE" ? (
<p <div
className="text-gray-500 dark:text-gray-300 text-center truncate w-full" className="text-gray-500 dark:text-gray-300 break-all w-full flex gap-2"
title={link.url} title={link.url}
> >
Editing:{" "} <FontAwesomeIcon icon={faLink} className="w-6 h-6" />
<Link href={link.url} target="_blank"> <Link href={link.url} target="_blank" className="w-full">
{link.url} {link.url}
</Link> </Link>
</p> </div>
) : null} ) : null}
{method === "CREATE" ? ( {method === "CREATE" ? (
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3"> <div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5"> <div className="sm:col-span-3 col-span-5">
<p className="text-sm text-black dark:text-white mb-2 font-bold"> <p className="text-sm text-black dark:text-white mb-2">
Address (URL) Address (URL)
<RequiredBadge />
</p> </p>
<TextInput <TextInput
value={link.url} value={link.url}
@ -189,7 +189,7 @@ export default function AddOrEditLink({
{optionsExpanded ? ( {optionsExpanded ? (
<div> <div>
<hr className="mb-3 border border-sky-100 dark:border-neutral-700" /> {/* <hr className="mb-3 border border-sky-100 dark:border-neutral-700" /> */}
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
<div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}> <div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}>
<p className="text-sm text-black dark:text-white mb-2">Name</p> <p className="text-sm text-black dark:text-white mb-2">Name</p>

View File

@ -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<LinkIncludingShortenedCollectionAndTags>();
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 (
<div className={`flex flex-col gap-3 sm:w-[35rem] w-80 pt-3`}>
{link?.screenshotPath && link?.screenshotPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">Screenshot</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover:opacity-50 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300"
/>
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.png`}
target="_blank"
className="cursor-pointer hover:opacity-50 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</Link>
</div>
</div>
) : undefined}
{link?.pdfPath && link.pdfPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">PDF</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover:opacity-50 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300"
/>
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.pdf`}
target="_blank"
className="cursor-pointer hover:opacity-50 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</Link>
</div>
</div>
) : undefined}
<div className="flex flex-col-reverse sm:flex-row gap-5 items-center justify-center">
{link?.collection.ownerId === session.data?.user.id ? (
<div
className="w-full text-center bg-sky-600 p-1 rounded-md cursor-pointer select-none mt-3"
onClick={() => updateArchive()}
>
<p>Update Preserved Formats</p>
<p className="text-xs">(re-fetch)</p>
</div>
) : undefined}
<Link
href={`https://web.archive.org/web/${link?.url.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="sm:mt-3 text-gray-500 dark:text-gray-300 duration-100 hover:opacity-50 flex gap-2 w-fit items-center text-sm"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-4 h-4"
/>
<p className="whitespace-nowrap">
View Latest Snapshot on archive.org
</p>
</Link>
</div>
</div>
);
}

View File

@ -1,94 +1,68 @@
import { Tab } from "@headlessui/react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import AddOrEditLink from "./AddOrEditLink"; import AddOrEditLink from "./AddOrEditLink";
import LinkDetails from "./LinkDetails"; import PreservedFormats from "./PreservedFormats";
type Props = type Props =
| { | {
toggleLinkModal: Function; toggleLinkModal: Function;
method: "CREATE"; method: "CREATE";
isOwnerOrMod?: boolean;
activeLink?: LinkIncludingShortenedCollectionAndTags; activeLink?: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number;
className?: string; className?: string;
} }
| { | {
toggleLinkModal: Function; toggleLinkModal: Function;
method: "UPDATE"; method: "UPDATE";
isOwnerOrMod: boolean;
activeLink: LinkIncludingShortenedCollectionAndTags; activeLink: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number; className?: string;
}
| {
toggleLinkModal: Function;
method: "FORMATS";
activeLink: LinkIncludingShortenedCollectionAndTags;
className?: string; className?: string;
}; };
export default function LinkModal({ export default function LinkModal({
className, className,
defaultIndex,
toggleLinkModal, toggleLinkModal,
isOwnerOrMod,
activeLink, activeLink,
method, method,
}: Props) { }: Props) {
return ( return (
<div className={className}> <div className={className}>
<Tab.Group defaultIndex={defaultIndex}> {/* {method === "CREATE" && (
{method === "CREATE" && ( <p className="text-xl text-black dark:text-white text-center">
<p className="text-xl text-black dark:text-white text-center"> New Link
New Link </p>
</p> )} */}
)} {activeLink && method === "UPDATE" ? (
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-black dark:text-white"> <>
{method === "UPDATE" && isOwnerOrMod && ( <p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">Edit Link</p>
<> <AddOrEditLink
<Tab toggleLinkModal={toggleLinkModal}
className={({ selected }) => method="UPDATE"
selected activeLink={activeLink}
? "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" </>
} ) : undefined}
>
Link Details
</Tab>
<Tab
className={({ selected }) =>
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
</Tab>
</>
)}
</Tab.List>
<Tab.Panels>
{activeLink && method === "UPDATE" && (
<Tab.Panel>
<LinkDetails
linkId={activeLink.id as number}
isOwnerOrMod={isOwnerOrMod}
/>
</Tab.Panel>
)}
<Tab.Panel> {method === "CREATE" ? (
{activeLink && method === "UPDATE" ? ( <>
<AddOrEditLink <p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
toggleLinkModal={toggleLinkModal} Create a New Link
method="UPDATE" </p>
activeLink={ <AddOrEditLink toggleLinkModal={toggleLinkModal} method="CREATE" />
activeLink as LinkIncludingShortenedCollectionAndTags </>
} ) : undefined}
/>
) : ( {activeLink && method === "FORMATS" ? (
<AddOrEditLink <>
toggleLinkModal={toggleLinkModal} <p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
method="CREATE" Manage Preserved Formats
/> </p>
)} <PreservedFormats activeLink={activeLink} />
</Tab.Panel> </>
</Tab.Panels> ) : undefined}
</Tab.Group>
</div> </div>
); );
} }

View File

@ -27,8 +27,6 @@ export default function ModalManagement() {
<LinkModal <LinkModal
toggleLinkModal={toggleModal} toggleLinkModal={toggleModal}
method={modal.method} method={modal.method}
isOwnerOrMod={modal.isOwnerOrMod as boolean}
defaultIndex={modal.defaultIndex}
activeLink={modal.active as LinkIncludingShortenedCollectionAndTags} activeLink={modal.active as LinkIncludingShortenedCollectionAndTags}
/> />
</Modal> </Modal>

88
layouts/LinkLayout.tsx Normal file
View File

@ -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 (
<>
<ModalManagement />
<div className="flex mx-auto">
<div className="hidden lg:block fixed left-5 h-screen">
<LinkSidebar />
</div>
<div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5">
<div className="flex gap-3 mb-5 duration-100">
<div
onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div>
<div
onClick={() => 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"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" />
Back
</div>
</div>
{children}
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler
className="h-full"
onClickOutside={toggleSidebar}
>
<div className="slide-right h-full shadow-lg">
<LinkSidebar onClick={() => setSidebar(false)} />
</div>
</ClickAwayHandler>
</div>
) : null}
</div>
</div>
</>
);
}

View File

@ -85,8 +85,6 @@ export default async function archive(
filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`, filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
}); });
console.log(JSON.parse(JSON.stringify(article)));
// Screenshot/PDF // Screenshot/PDF
let faulty = true; let faulty = true;

View File

@ -16,7 +16,7 @@ export default async function getLinkById(userId: number, linkId: number) {
| null; | null;
const memberHasAccess = collectionIsAccessible?.members.some( const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate (e: UsersAndCollections) => e.userId === userId
); );
const isCollectionOwner = collectionIsAccessible?.ownerId === userId; const isCollectionOwner = collectionIsAccessible?.ownerId === userId;

View File

@ -5,7 +5,6 @@ import archive from "@/lib/api/archive";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
export default async function links(req: NextApiRequest, res: NextApiResponse) { export default async function links(req: NextApiRequest, res: NextApiResponse) {
console.log("hi");
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {

257
pages/links/[id].tsx Normal file
View File

@ -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<LinkIncludingShortenedCollectionAndTags>();
const [linkContent, setLinkContent] = useState<LinkContent>();
const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
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 (
<LinkLayout>
<div
className={`flex flex-col max-w-screen-md h-full ${
theme === "dark" ? "banner-dark-mode" : "banner-light-mode"
}`}
>
<div
id="link-banner"
className="link-banner p-5 mb-6 relative bg-opacity-10 border border-solid border-sky-100 dark:border-neutral-700 shadow-md"
>
<div id="link-banner-inner" className="link-banner-inner"></div>
<div className={`relative flex gap-5 items-start`}>
{!imageError && link?.url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={42}
height={42}
alt=""
id={"favicon-" + link.id}
className="select-none mt-2 w-10 rounded-md shadow border-[3px] border-white dark:border-neutral-900 bg-white dark:bg-neutral-900 aspect-square"
draggable="false"
onLoad={(e) => {
try {
const color = colorThief.getPalette(
e.target as HTMLImageElement,
4
);
setColorPalette(color);
} catch (err) {
console.log(err);
}
}}
onError={(e) => {
setImageError(true);
}}
/>
)}
<div>
<p className="capitalize text-3xl font-thin">
{unescapeString(link?.name || link?.description || "")}
</p>
<div className="flex gap-2 text-sm text-gray-500 dark:text-gray-300">
<p className=" min-w-fit">
{link?.createdAt
? new Date(link?.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: undefined}
</p>
{link?.url ? (
<>
<p></p>
<Link
href={link?.url || ""}
target="_blank"
className="hover:underline break-all"
>
{isValidUrl(link?.url || "")
? new URL(link?.url as string).host
: undefined}
</Link>
</>
) : undefined}
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-5 h-full">
{link &&
link?.readabilityPath &&
link?.readabilityPath !== "pending" ? (
<div
className="line-break px-5"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(linkContent?.content || "") || "",
}}
></div>
) : (
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800">
<p className="text-center text-2xl text-black dark:text-white">
There is no reader view for this webpage
</p>
<p className="text-center text-sm text-black dark:text-white">
{link?.collection.ownerId === userId
? "You can update (refetch) the preserved formats by managing them below"
: "The collections owners can refetch the preserved formats"}
</p>
{link?.collection.ownerId === userId ? (
<div
onClick={() =>
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"
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-5 h-5 duration-100"
/>
<p>Manage preserved formats</p>
</div>
) : undefined}
</div>
)}
</div>
</div>
</LinkLayout>
);
}

View File

@ -72,11 +72,23 @@ const useLinkStore = create<LinkStore>()((set) => ({
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
set((state) => ({ set((state) => {
links: state.links.map((e) => const linkExists = state.links.some(
e.id === data.response.id ? data.response : e (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 }; return { ok: response.ok, data: data.response };

View File

@ -9,17 +9,19 @@ type Modal =
modal: "LINK"; modal: "LINK";
state: boolean; state: boolean;
method: "CREATE"; method: "CREATE";
isOwnerOrMod?: boolean;
active?: LinkIncludingShortenedCollectionAndTags; active?: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number;
} }
| { | {
modal: "LINK"; modal: "LINK";
state: boolean; state: boolean;
method: "UPDATE"; method: "UPDATE";
isOwnerOrMod: boolean;
active: LinkIncludingShortenedCollectionAndTags; active: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number; }
| {
modal: "LINK";
state: boolean;
method: "FORMATS";
active: LinkIncludingShortenedCollectionAndTags;
} }
| { | {
modal: "COLLECTION"; modal: "COLLECTION";

View File

@ -129,72 +129,9 @@
/* For the Link banner */ /* For the Link banner */
.link-banner { .link-banner {
/* box-shadow: inset 0px 10px 20px 20px #ffffff; */
opacity: 25%;
z-index: 0; z-index: 0;
} border-radius: 1rem;
.link-banner .link-banner-inner { height: fit-content;
/* 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%
);
} }
/* Theme */ /* Theme */
@ -227,6 +164,12 @@
@apply dark:text-white; @apply dark:text-white;
} }
} }
.react-select__clear-indicator * {
display: none;
width: 0;
margin: 0;
padding: 0;
}
.sky-shadow { .sky-shadow {
box-shadow: 0px 0px 3px #0ea5e9; box-shadow: 0px 0px 3px #0ea5e9;
@ -239,3 +182,7 @@
.primary-btn-gradient:hover { .primary-btn-gradient:hover {
box-shadow: inset 0px -15px 10px #059bf8; box-shadow: inset 0px -15px 10px #059bf8;
} }
.line-break * {
overflow-x: auto;
}