better link card

This commit is contained in:
daniel31x13 2023-12-23 12:11:47 -05:00
parent 98106b9f25
commit 848a33a53e
15 changed files with 701 additions and 259 deletions

View File

@ -7,7 +7,7 @@ export default function CardView({
links: LinkIncludingShortenedCollectionAndTags[]; links: LinkIncludingShortenedCollectionAndTags[];
}) { }) {
return ( return (
<div className="grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5"> <div className="grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{links.map((e, i) => { {links.map((e, i) => {
return <LinkCard key={i} link={e} count={i} />; return <LinkCard key={i} link={e} count={i} />;
})} })}

View File

@ -1,4 +1,5 @@
import { import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
@ -9,8 +10,10 @@ import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon"; import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link"; import Link from "next/link";
import LinkIcon from "./LinkComponents/LinkIcon";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -47,30 +50,48 @@ export default function LinkGrid({ link, count, className }: Props) {
}, [collections, links]); }, [collections, links]);
return ( return (
<div className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative p-3"> <div className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative">
<div className="relative rounded-t-2xl h-52">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
width={1280}
height={720}
alt=""
className="rounded-t-2xl select-none object-cover z-10 h-52 w-full shadow"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : undefined}
<div <div
onClick={() => link.url && window.open(link.url || "", "_blank")} style={{
className="cursor-pointer" background: "radial-gradient(circle, #ffffff, transparent)",
}}
className="absolute top-0 left-0 right-0 rounded-t-2xl flex items-center justify-center h-52 shadow rounded-md"
> >
<LinkIcon link={link} width="w-12 mb-3" /> <LinkIcon link={link} width="w-12" />
</div>
</div>
<div className="p-3">
<p className="truncate w-full"> <p className="truncate w-full">
{unescapeString(link.name || link.description) || link.url} {unescapeString(link.name || link.description) || link.url}
</p> </p>
<div className="mt-1 flex flex-col text-xs text-neutral"> <div className="mt-1 flex flex-col text-xs text-neutral">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LinkCollection link={link} collection={collection} /> <LinkCollection link={link} collection={collection} />
&middot; &middot;
{link.url ? ( {link.url ? (
<div <Link
onClick={(e) => { href={link.url}
e.preventDefault(); target="_blank"
window.open(link.url || "", "_blank");
}}
className="flex items-center hover:opacity-60 cursor-pointer duration-100" className="flex items-center hover:opacity-60 cursor-pointer duration-100"
> >
<p className="truncate">{shortendURL}</p> <p className="truncate">{shortendURL}</p>
</div> </Link>
) : ( ) : (
<div className="badge badge-primary badge-sm my-1"> <div className="badge badge-primary badge-sm my-1">
{link.type} {link.type}

View File

@ -18,9 +18,11 @@ export default function LinkIcon({
" " + " " +
(width || "w-12"); (width || "w-12");
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
return ( return (
<div> <div>
{link.url && url ? ( {link.url && url && showFavicon ? (
<Image <Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`} src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64} width={64}
@ -28,11 +30,14 @@ export default function LinkIcon({
alt="" alt=""
className={iconClasses} className={iconClasses}
draggable="false" draggable="false"
onError={(e) => { onError={() => {
const target = e.target as HTMLElement; setShowFavicon(false);
target.style.display = "none";
}} }}
/> />
) : showFavicon === false ? (
<div className="flex items-center justify-center h-12 w-12 bg-base-200 rounded-md shadow">
<i className="bi-link-45deg text-4xl text-primary"></i>
</div>
) : link.type === "pdf" ? ( ) : link.type === "pdf" ? (
<i className={`bi-file-earmark-pdf ${iconClasses}`}></i> <i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
) : link.type === "image" ? ( ) : link.type === "image" ? (

View File

@ -8,6 +8,7 @@ import DOMPurify from "dompurify";
import { Collection, Link, User } from "@prisma/client"; import { Collection, Link, User } from "@prisma/client";
import validateUrlSize from "./validateUrlSize"; import validateUrlSize from "./validateUrlSize";
import removeFile from "./storage/removeFile"; import removeFile from "./storage/removeFile";
import Jimp from "jimp";
type LinksAndCollectionAndOwner = Link & { type LinksAndCollectionAndOwner = Link & {
collection: Collection & { collection: Collection & {
@ -55,6 +56,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
? "pending" ? "pending"
: undefined, : undefined,
readable: !link.readable?.startsWith("archive") ? "pending" : undefined, readable: !link.readable?.startsWith("archive") ? "pending" : undefined,
preview: !link.readable?.startsWith("archive") ? "pending" : undefined,
lastPreserved: new Date().toISOString(), lastPreserved: new Date().toISOString(),
}, },
}); });
@ -65,10 +67,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
} else if (linkType === "pdf") { } else if (linkType === "pdf") {
await pdfHandler(link); // archive pdf await pdfHandler(link); // archive pdf
return; return;
} else if (user.archiveAsPDF || user.archiveAsScreenshot) { } else if ((user.archiveAsPDF || user.archiveAsScreenshot) && link.url) {
// archive url // archive url
link.url &&
(await page.goto(link.url, { waitUntil: "domcontentloaded" })); await page.goto(link.url, { waitUntil: "domcontentloaded" });
const content = await page.content(); const content = await page.content();
@ -110,11 +112,81 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
}); });
} }
// Preview
const ogImageUrl = await page.evaluate(() => {
const metaTag = document.querySelector('meta[property="og:image"]');
return metaTag ? (metaTag as any).content : null;
});
if (ogImageUrl) {
console.log("Found og:image URL:", ogImageUrl);
// Download the image
const imageResponse = await page.goto(ogImageUrl);
// Check if imageResponse is not null
if (imageResponse) {
const buffer = await imageResponse.body();
// Check if buffer is not null
if (buffer) {
// Load the image using Jimp
Jimp.read(buffer, async (err, image) => {
if (image) {
image?.resize(1280, Jimp.AUTO).quality(20);
await image?.writeAsync("og_image.jpg");
const processedBuffer = await image?.getBufferAsync(
Jimp.MIME_JPEG
);
createFile({
data: processedBuffer,
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
}).then(() => {
return prisma.link.update({
where: { id: link.id },
data: {
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
},
});
});
}
}).catch((err) => {
console.error("Error processing the image:", err);
});
} else {
console.log("No image data found.");
}
} else {
console.log("Image response is null.");
}
} else {
console.log("No og:image found");
page
.screenshot({ type: "jpeg", quality: 20 })
.then((screenshot) => {
return createFile({
data: screenshot,
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
});
})
.then(() => {
return prisma.link.update({
where: { id: link.id },
data: {
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
},
});
});
}
// Screenshot/PDF // Screenshot/PDF
await page.evaluate( await page.evaluate(
autoScroll, autoScroll,
Number(process.env.AUTOSCROLL_TIMEOUT) || 30 Number(process.env.AUTOSCROLL_TIMEOUT) || 30
); );
// Check if the user hasn't deleted the link by the time we're done scrolling // Check if the user hasn't deleted the link by the time we're done scrolling
const linkExists = await prisma.link.findUnique({ const linkExists = await prisma.link.findUnique({
where: { id: link.id }, where: { id: link.id },
@ -176,6 +248,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
await prisma.link.update({ await prisma.link.update({
where: { id: link.id }, where: { id: link.id },
data: { data: {
lastPreserved: new Date().toISOString(),
readable: !finalLink.readable?.startsWith("archives") readable: !finalLink.readable?.startsWith("archives")
? "unavailable" ? "unavailable"
: undefined, : undefined,
@ -185,6 +258,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
pdf: !finalLink.pdf?.startsWith("archives") pdf: !finalLink.pdf?.startsWith("archives")
? "unavailable" ? "unavailable"
: undefined, : undefined,
preview: !finalLink.preview?.startsWith("archives")
? "unavailable"
: undefined,
}, },
}); });
else { else {
@ -193,6 +269,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
removeFile({ removeFile({
filePath: `archives/${link.collectionId}/${link.id}_readability.json`, filePath: `archives/${link.collectionId}/${link.id}_readability.json`,
}); });
removeFile({
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
});
} }
await browser.close(); await browser.close();

View File

@ -5,8 +5,8 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql"); const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
let order: any; let order: any;
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" };
else if (query.sort === Sort.NameZA) order = { name: "desc" }; else if (query.sort === Sort.NameZA) order = { name: "desc" };
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" }; else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
@ -145,7 +145,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
select: { id: true }, select: { id: true },
}, },
}, },
orderBy: order || { createdAt: "desc" }, orderBy: order || { id: "desc" },
}); });
return { response: links, status: 200 }; return { response: links, status: 200 };

View File

@ -7,8 +7,8 @@ export default async function getLink(
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql"); const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
let order: any; let order: any;
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" };
else if (query.sort === Sort.NameZA) order = { name: "desc" }; else if (query.sort === Sort.NameZA) order = { name: "desc" };
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" }; else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
@ -81,7 +81,7 @@ export default async function getLink(
include: { include: {
tags: true, tags: true,
}, },
orderBy: order || { createdAt: "desc" }, orderBy: order || { id: "desc" },
}); });
return { response: links, status: 200 }; return { response: links, status: 200 };

View File

@ -21,3 +21,12 @@ export function readabilityAvailable(link: any) {
link.readable !== "unavailable" link.readable !== "unavailable"
); );
} }
export function previewAvailable(link: any) {
return (
link &&
link.preview &&
link.preview !== "pending" &&
link.preview !== "unavailable"
);
}

View File

@ -40,6 +40,7 @@
"eslint-config-next": "13.4.9", "eslint-config-next": "13.4.9",
"formidable": "^3.5.1", "formidable": "^3.5.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"jimp": "^0.22.10",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"lottie-web": "^5.12.2", "lottie-web": "^5.12.2",
"micro": "^10.0.1", "micro": "^10.0.1",
@ -53,7 +54,6 @@
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-image-file-resizer": "^0.4.8", "react-image-file-resizer": "^0.4.8",
"react-select": "^5.7.4", "react-select": "^5.7.4",
"sharp": "^0.32.1",
"stripe": "^12.13.0", "stripe": "^12.13.0",
"zustand": "^4.3.8" "zustand": "^4.3.8"
}, },

View File

@ -19,6 +19,7 @@ export const config = {
export default async function Index(req: NextApiRequest, res: NextApiResponse) { export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const linkId = Number(req.query.linkId); const linkId = Number(req.query.linkId);
const format = Number(req.query.format); const format = Number(req.query.format);
const isPreview = Boolean(req.query.preview);
let suffix: string; let suffix: string;
@ -55,6 +56,15 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
.status(401) .status(401)
.json({ response: "You don't have access to this collection." }); .json({ response: "You don't have access to this collection." });
if (isPreview) {
const { file, contentType, status } = await readFile(
`archives/preview/${collectionIsAccessible.id}/${linkId}.jpeg`
);
res.setHeader("Content-Type", contentType).status(status as number);
return res.send(file);
} else {
const { file, contentType, status } = await readFile( const { file, contentType, status } = await readFile(
`archives/${collectionIsAccessible.id}/${linkId + suffix}` `archives/${collectionIsAccessible.id}/${linkId + suffix}`
); );
@ -63,6 +73,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.send(file); return res.send(file);
} }
}
// else if (req.method === "POST") { // else if (req.method === "POST") {
// const user = await verifyUser({ req, res }); // const user = await verifyUser({ req, res });
// if (!user) return; // if (!user) return;

View File

@ -76,6 +76,7 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
image: null, image: null,
pdf: null, pdf: null,
readable: null, readable: null,
preview: null,
}, },
}); });
@ -88,4 +89,7 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
await removeFile({ await removeFile({
filePath: `archives/${link.collection.id}/${link.id}_readability.json`, filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
}); });
await removeFile({
filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
});
}; };

View File

@ -38,7 +38,7 @@ export default function Collections() {
</div> </div>
</div> </div>
<div className="grid xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <div className="grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections {sortedCollections
.filter((e) => e.ownerId === data?.user.id) .filter((e) => e.ownerId === data?.user.id)
.map((e, i) => { .map((e, i) => {
@ -62,7 +62,7 @@ export default function Collections() {
description={"Shared collections you're a member of"} description={"Shared collections you're a member of"}
/> />
<div className="grid xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <div className="grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections {sortedCollections
.filter((e) => e.ownerId !== data?.user.id) .filter((e) => e.ownerId !== data?.user.id)
.map((e, i) => { .map((e, i) => {

View File

@ -146,7 +146,7 @@ export default function Dashboard() {
{links[0] ? ( {links[0] ? (
<div className="w-full"> <div className="w-full">
<div <div
className={`grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5 w-full`} className={`grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 w-full`}
> >
{links.slice(0, showLinks).map((e, i) => ( {links.slice(0, showLinks).map((e, i) => (
<LinkCard key={i} link={e} count={i} /> <LinkCard key={i} link={e} count={i} />
@ -261,7 +261,7 @@ export default function Dashboard() {
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="w-full"> <div className="w-full">
<div <div
className={`grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5 w-full`} className={`grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 w-full`}
> >
{links {links
.filter((e) => e.pinnedBy && e.pinnedBy[0]) .filter((e) => e.pinnedBy && e.pinnedBy[0])

BIN
resized_og_image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -60,7 +60,7 @@ async function processBatch() {
], ],
}, },
take: archiveTakeCount, take: archiveTakeCount,
orderBy: { createdAt: "asc" }, orderBy: { id: "asc" },
include: { include: {
collection: { collection: {
include: { include: {
@ -114,10 +114,17 @@ async function processBatch() {
{ {
readable: "pending", readable: "pending",
}, },
///////////////////////
{
preview: null,
},
{
preview: "pending",
},
], ],
}, },
take: archiveTakeCount, take: archiveTakeCount,
orderBy: { createdAt: "desc" }, orderBy: { id: "desc" },
include: { include: {
collection: { collection: {
include: { include: {

744
yarn.lock

File diff suppressed because it is too large Load Diff