@@ -93,84 +92,95 @@ export default function LinkLayout({ children }: Props) {
router.push(`/collections/${linkCollection?.id}`)}
+ onClick={() => {
+ if (router.pathname.startsWith("/public")) {
+ router.push(
+ `/public/collections/${
+ linkCollection?.id || link?.collection.id
+ }`
+ );
+ } else {
+ router.push(`/collections/${linkCollection?.id}`);
+ }
+ }}
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{" "}
- to {linkCollection?.name}
+ to{" "}
+
+ {linkCollection?.name || link?.collection?.name}
+
-
- {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?.collection?.ownerId === userId ||
+ linkCollection?.members.some(
+ (e) => e.userId === userId && e.canUpdate
+ ) ? (
{
link
? setModal({
modal: "LINK",
state: true,
active: link,
- method: "FORMATS",
+ method: "UPDATE",
})
: 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`}
>
+ ) : undefined}
- {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}
+
{
+ 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}
diff --git a/lib/api/controllers/links/linkId/getLinkById.ts b/lib/api/controllers/links/linkId/getLinkById.ts
index 83a3ceb..bf872c2 100644
--- a/lib/api/controllers/links/linkId/getLinkById.ts
+++ b/lib/api/controllers/links/linkId/getLinkById.ts
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/api/db";
-import { Collection, Link, UsersAndCollections } from "@prisma/client";
+import { Collection, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
export default async function getLinkById(userId: number, linkId: number) {
@@ -27,7 +27,7 @@ export default async function getLinkById(userId: number, linkId: number) {
status: 401,
};
else {
- const updatedLink = await prisma.link.findUnique({
+ const link = await prisma.link.findUnique({
where: {
id: linkId,
},
@@ -43,6 +43,6 @@ export default async function getLinkById(userId: number, linkId: number) {
},
});
- return { response: updatedLink, status: 200 };
+ return { response: link, status: 200 };
}
}
diff --git a/lib/api/controllers/public/links/linkId/getLinkById.ts b/lib/api/controllers/public/links/linkId/getLinkById.ts
new file mode 100644
index 0000000..2e1d87d
--- /dev/null
+++ b/lib/api/controllers/public/links/linkId/getLinkById.ts
@@ -0,0 +1,24 @@
+import { prisma } from "@/lib/api/db";
+
+export default async function getLinkById(linkId: number) {
+ if (!linkId)
+ return {
+ response: "Please choose a valid link.",
+ status: 401,
+ };
+
+ const link = await prisma.link.findFirst({
+ where: {
+ id: linkId,
+ collection: {
+ isPublic: true,
+ },
+ },
+ include: {
+ tags: true,
+ collection: true,
+ },
+ });
+
+ return { response: link, status: 200 };
+}
diff --git a/lib/api/getPermission.ts b/lib/api/getPermission.ts
index 3d1b2bc..cefd4e1 100644
--- a/lib/api/getPermission.ts
+++ b/lib/api/getPermission.ts
@@ -1,24 +1,35 @@
import { prisma } from "@/lib/api/db";
type Props = {
- userId: number;
+ userId?: number;
collectionId?: number;
linkId?: number;
+ isPublic?: boolean;
};
export default async function getPermission({
userId,
collectionId,
linkId,
+ isPublic,
}: Props) {
if (linkId) {
const check = await prisma.collection.findFirst({
where: {
- links: {
- some: {
- id: linkId,
+ [isPublic ? "OR" : "AND"]: [
+ {
+ id: collectionId,
+ OR: [{ ownerId: userId }, { members: { some: { userId } } }],
+ links: {
+ some: {
+ id: linkId,
+ },
+ },
},
- },
+ {
+ isPublic: isPublic ? true : undefined,
+ },
+ ],
},
include: { members: true },
});
@@ -27,10 +38,15 @@ export default async function getPermission({
} else if (collectionId) {
const check = await prisma.collection.findFirst({
where: {
- AND: {
- id: collectionId,
- OR: [{ ownerId: userId }, { members: { some: { userId } } }],
- },
+ [isPublic ? "OR" : "AND"]: [
+ {
+ id: collectionId,
+ OR: [{ ownerId: userId }, { members: { some: { userId } } }],
+ },
+ {
+ isPublic: isPublic ? true : undefined,
+ },
+ ],
},
include: { members: true },
});
diff --git a/pages/api/v1/archives/[...params].ts b/pages/api/v1/archives/[...params].ts
index 1436c04..065ba2f 100644
--- a/pages/api/v1/archives/[...params].ts
+++ b/pages/api/v1/archives/[...params].ts
@@ -1,20 +1,20 @@
import type { NextApiRequest, NextApiResponse } from "next";
import getPermission from "@/lib/api/getPermission";
import readFile from "@/lib/api/storage/readFile";
-import verifyUser from "@/lib/api/verifyUser";
+import { getToken } from "next-auth/jwt";
export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (!req.query.params)
return res.status(401).json({ response: "Invalid parameters." });
- const user = await verifyUser({ req, res });
- if (!user) return;
+ const token = await getToken({ req });
+ const userId = token?.id;
const collectionId = req.query.params[0];
const linkId = req.query.params[1];
const collectionIsAccessible = await getPermission({
- userId: user.id,
+ userId,
collectionId: Number(collectionId),
});
diff --git a/pages/api/v1/avatar/[id].ts b/pages/api/v1/avatar/[id].ts
index f20b9b6..3399cc3 100644
--- a/pages/api/v1/avatar/[id].ts
+++ b/pages/api/v1/avatar/[id].ts
@@ -2,26 +2,43 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db";
import readFile from "@/lib/api/storage/readFile";
import verifyUser from "@/lib/api/verifyUser";
+import { getToken } from "next-auth/jwt";
export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const queryId = Number(req.query.id);
- const user = await verifyUser({ req, res });
- if (!user) return;
-
if (!queryId)
return res
.setHeader("Content-Type", "text/plain")
.status(401)
.send("Invalid parameters.");
- if (user.id !== queryId) {
- const targetUser = await prisma.user.findUnique({
+ const token = await getToken({ req });
+ const userId = token?.id;
+
+ const targetUser = await prisma.user.findUnique({
+ where: {
+ id: queryId,
+ },
+ include: {
+ whitelistedUsers: true,
+ },
+ });
+
+ if (targetUser?.isPrivate) {
+ if (!userId) {
+ return res
+ .setHeader("Content-Type", "text/plain")
+ .status(400)
+ .send("File inaccessible.");
+ }
+
+ const user = await prisma.user.findUnique({
where: {
- id: queryId,
+ id: userId,
},
include: {
- whitelistedUsers: true,
+ subscriptions: true,
},
});
@@ -29,15 +46,18 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
(whitelistedUsername) => whitelistedUsername.username
);
- if (
- targetUser?.isPrivate &&
- user.username &&
- !whitelistedUsernames?.includes(user.username)
- ) {
+ if (!user?.username) {
return res
.setHeader("Content-Type", "text/plain")
.status(400)
- .send("File not found.");
+ .send("File inaccessible.");
+ }
+
+ if (user.username && !whitelistedUsernames?.includes(user.username)) {
+ return res
+ .setHeader("Content-Type", "text/plain")
+ .status(400)
+ .send("File inaccessible.");
}
}
diff --git a/pages/api/v1/public/collections/[id].ts b/pages/api/v1/public/collections/[id].ts
index c9b2fde..04178f8 100644
--- a/pages/api/v1/public/collections/[id].ts
+++ b/pages/api/v1/public/collections/[id].ts
@@ -1,7 +1,7 @@
import getPublicCollection from "@/lib/api/controllers/public/collections/getPublicCollection";
import type { NextApiRequest, NextApiResponse } from "next";
-export default async function collections(
+export default async function collection(
req: NextApiRequest,
res: NextApiResponse
) {
diff --git a/pages/api/v1/public/collections/links/[id].ts b/pages/api/v1/public/collections/links/[id].ts
deleted file mode 100644
index e69de29..0000000
diff --git a/pages/api/v1/public/links/[id].ts b/pages/api/v1/public/links/[id].ts
new file mode 100644
index 0000000..b3e854d
--- /dev/null
+++ b/pages/api/v1/public/links/[id].ts
@@ -0,0 +1,13 @@
+import getLinkById from "@/lib/api/controllers/public/links/linkId/getLinkById";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default async function link(req: NextApiRequest, res: NextApiResponse) {
+ if (!req?.query?.id) {
+ return res.status(401).json({ response: "Please choose a valid link." });
+ }
+
+ if (req.method === "GET") {
+ const link = await getLinkById(Number(req?.query?.id));
+ return res.status(link.status).json({ response: link.response });
+ }
+}
diff --git a/pages/links/[id].tsx b/pages/links/[id].tsx
index b05d0fb..6f18235 100644
--- a/pages/links/[id].tsx
+++ b/pages/links/[id].tsx
@@ -146,7 +146,7 @@ export default function Index() {
>
diff --git a/pages/public/collections/[id].tsx b/pages/public/collections/[id].tsx
index c92d3c6..eea44bc 100644
--- a/pages/public/collections/[id].tsx
+++ b/pages/public/collections/[id].tsx
@@ -57,8 +57,6 @@ export default function PublicCollections() {
image: "",
});
- useEffect(() => {}, []);
-
const [searchFilter, setSearchFilter] = useState({
name: true,
url: true,
diff --git a/pages/public/links/[id].tsx b/pages/public/links/[id].tsx
new file mode 100644
index 0000000..d95c6b8
--- /dev/null
+++ b/pages/public/links/[id].tsx
@@ -0,0 +1,301 @@
+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, faFolder } from "@fortawesome/free-solid-svg-icons";
+import useModalStore from "@/store/modals";
+import { useSession } from "next-auth/react";
+import { isProbablyReaderable } from "@mozilla/readability";
+
+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), true);
+ }
+ };
+
+ 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" ||
+ link?.readabilityPath === "pending"
+ ) {
+ interval = setInterval(() => getLink(link.id as number, true), 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 (
+
+
+
+
+
+
+
+ {!imageError && link?.url && (
+
{
+ try {
+ const color = colorThief.getPalette(
+ e.target as HTMLImageElement,
+ 4
+ );
+
+ setColorPalette(color);
+ } catch (err) {
+ console.log(err);
+ }
+ }}
+ onError={(e) => {
+ setImageError(true);
+ }}
+ />
+ )}
+
+
+
+ {link?.createdAt
+ ? new Date(link?.createdAt).toLocaleString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+ : undefined}
+
+ {link?.url ? (
+ <>
+
•
+
+ {isValidUrl(link?.url || "")
+ ? new URL(link?.url as string).host
+ : undefined}
+
+ >
+ ) : undefined}
+
+
+
+
+
+ {unescapeString(link?.name || link?.description || "")}
+
+
+
+
+
+
+ {link?.collection?.name}
+
+
+ {link?.tags.map((e, i) => (
+
+
+ {e.name}
+
+
+ ))}
+
+
+
+
+
+
+ {link?.readabilityPath?.startsWith("archives") ? (
+
+ ) : (
+
+ {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/store/links.ts b/store/links.ts
index 9362742..0648b6f 100644
--- a/store/links.ts
+++ b/store/links.ts
@@ -17,7 +17,7 @@ type LinkStore = {
addLink: (
body: LinkIncludingShortenedCollectionAndTags
) => Promise;
- getLink: (linkId: number) => Promise;
+ getLink: (linkId: number, publicRoute?: boolean) => Promise;
updateLink: (
link: LinkIncludingShortenedCollectionAndTags
) => Promise;
@@ -66,8 +66,12 @@ const useLinkStore = create()((set) => ({
return { ok: response.ok, data: data.response };
},
- getLink: async (linkId) => {
- const response = await fetch(`/api/v1/links/${linkId}`);
+ getLink: async (linkId, publicRoute) => {
+ const path = publicRoute
+ ? `/api/v1/public/links/${linkId}`
+ : `/api/v1/links/${linkId}`;
+
+ const response = await fetch(path);
const data = await response.json();
diff --git a/styles/globals.css b/styles/globals.css
index 5769211..d1d26d6 100644
--- a/styles/globals.css
+++ b/styles/globals.css
@@ -190,7 +190,11 @@ body {
/* Reader view custom stylings */
.reader-view {
- line-height: 3rem;
+ line-height: 2.8rem;
+}
+.reader-view p {
+ font-size: 1.15rem;
+ line-height: 2.5rem;
}
.reader-view h1 {
font-size: 2.2rem;