@@ -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/collections/getPublicCollection.ts b/lib/api/controllers/public/collections/getPublicCollection.ts
new file mode 100644
index 0000000..6c5de3a
--- /dev/null
+++ b/lib/api/controllers/public/collections/getPublicCollection.ts
@@ -0,0 +1,32 @@
+import { prisma } from "@/lib/api/db";
+
+export default async function getPublicCollection(id: number) {
+ const collection = await prisma.collection.findFirst({
+ where: {
+ id,
+ isPublic: true,
+ },
+ include: {
+ members: {
+ include: {
+ user: {
+ select: {
+ username: true,
+ name: true,
+ image: true,
+ },
+ },
+ },
+ },
+ _count: {
+ select: { links: true },
+ },
+ },
+ });
+
+ if (collection) {
+ return { response: collection, status: 200 };
+ } else {
+ return { response: "Collection not found.", status: 400 };
+ }
+}
diff --git a/lib/api/controllers/public/getCollection.ts b/lib/api/controllers/public/getCollection.ts
deleted file mode 100644
index a9a2753..0000000
--- a/lib/api/controllers/public/getCollection.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { prisma } from "@/lib/api/db";
-import { PublicLinkRequestQuery } from "@/types/global";
-
-export default async function getCollection(body: string) {
- const query: PublicLinkRequestQuery = JSON.parse(decodeURIComponent(body));
- console.log(query);
-
- let data;
-
- const collection = await prisma.collection.findFirst({
- where: {
- id: query.collectionId,
- isPublic: true,
- },
- });
-
- if (collection) {
- const links = await prisma.link.findMany({
- take: Number(process.env.PAGINATION_TAKE_COUNT) || 20,
- skip: query.cursor ? 1 : undefined,
- cursor: query.cursor
- ? {
- id: query.cursor,
- }
- : undefined,
- where: {
- collection: {
- id: query.collectionId,
- },
- },
- include: {
- tags: true,
- },
- orderBy: {
- createdAt: "desc",
- },
- });
-
- data = { ...collection, links: [...links] };
-
- return { response: data, status: 200 };
- } else {
- return { response: "Collection not found...", status: 400 };
- }
-}
diff --git a/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts b/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts
new file mode 100644
index 0000000..f4113b6
--- /dev/null
+++ b/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts
@@ -0,0 +1,88 @@
+import { prisma } from "@/lib/api/db";
+import { LinkRequestQuery, Sort } from "@/types/global";
+
+export default async function getLink(
+ query: Omit
+) {
+ const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
+
+ let order: any;
+ if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" };
+ else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" };
+ else if (query.sort === Sort.NameAZ) order = { name: "asc" };
+ else if (query.sort === Sort.NameZA) order = { name: "desc" };
+ else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
+ else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
+
+ const searchConditions = [];
+
+ if (query.searchQueryString) {
+ if (query.searchByName) {
+ searchConditions.push({
+ name: {
+ contains: query.searchQueryString,
+ mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
+ },
+ });
+ }
+
+ if (query.searchByUrl) {
+ searchConditions.push({
+ url: {
+ contains: query.searchQueryString,
+ mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
+ },
+ });
+ }
+
+ if (query.searchByDescription) {
+ searchConditions.push({
+ description: {
+ contains: query.searchQueryString,
+ mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
+ },
+ });
+ }
+
+ if (query.searchByTextContent) {
+ searchConditions.push({
+ textContent: {
+ contains: query.searchQueryString,
+ mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
+ },
+ });
+ }
+
+ if (query.searchByTags) {
+ searchConditions.push({
+ tags: {
+ some: {
+ name: {
+ contains: query.searchQueryString,
+ mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
+ },
+ },
+ },
+ });
+ }
+ }
+
+ const links = await prisma.link.findMany({
+ take: Number(process.env.PAGINATION_TAKE_COUNT) || 20,
+ skip: query.cursor ? 1 : undefined,
+ cursor: query.cursor ? { id: query.cursor } : undefined,
+ where: {
+ collection: {
+ id: query.collectionId,
+ isPublic: true,
+ },
+ [query.searchQueryString ? "OR" : "AND"]: [...searchConditions],
+ },
+ include: {
+ tags: true,
+ },
+ orderBy: order || { createdAt: "desc" },
+ });
+
+ return { response: links, 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/controllers/public/users/getPublicUserById.ts b/lib/api/controllers/public/users/getPublicUser.ts
similarity index 96%
rename from lib/api/controllers/public/users/getPublicUserById.ts
rename to lib/api/controllers/public/users/getPublicUser.ts
index 8f7ea48..fff10a5 100644
--- a/lib/api/controllers/public/users/getPublicUserById.ts
+++ b/lib/api/controllers/public/users/getPublicUser.ts
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/api/db";
-export default async function getPublicUserById(
+export default async function getPublicUser(
targetId: number | string,
isId: boolean,
requestingId?: number
diff --git a/lib/api/controllers/tags/getTags.ts b/lib/api/controllers/tags/getTags.ts
index e1b007f..85a8d17 100644
--- a/lib/api/controllers/tags/getTags.ts
+++ b/lib/api/controllers/tags/getTags.ts
@@ -30,6 +30,11 @@ export default async function getTags(userId: number) {
},
],
},
+ include: {
+ _count: {
+ select: { links: true },
+ },
+ },
// orderBy: {
// links: {
// _count: "desc",
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/lib/client/getPublicCollectionData.ts b/lib/client/getPublicCollectionData.ts
index b86c173..283733b 100644
--- a/lib/client/getPublicCollectionData.ts
+++ b/lib/client/getPublicCollectionData.ts
@@ -1,33 +1,17 @@
-import {
- PublicCollectionIncludingLinks,
- PublicLinkRequestQuery,
-} from "@/types/global";
+import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { Dispatch, SetStateAction } from "react";
const getPublicCollectionData = async (
collectionId: number,
- prevData: PublicCollectionIncludingLinks,
- setData: Dispatch>
+ setData: Dispatch<
+ SetStateAction
+ >
) => {
- const requestBody: PublicLinkRequestQuery = {
- cursor: prevData?.links?.at(-1)?.id,
- collectionId,
- };
-
- const encodedData = encodeURIComponent(JSON.stringify(requestBody));
-
- const res = await fetch(
- "/api/v1/public/collections?body=" + encodeURIComponent(encodedData)
- );
+ const res = await fetch("/api/v1/public/collections/" + collectionId);
const data = await res.json();
- prevData
- ? setData({
- ...data.response,
- links: [...prevData.links, ...data.response.links],
- })
- : setData(data.response);
+ setData(data.response);
return data;
};
diff --git a/package.json b/package.json
index a77e211..0078a7b 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,6 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.15",
"@mozilla/readability": "^0.4.4",
- "@next/font": "13.4.9",
"@prisma/client": "^4.16.2",
"@stripe/stripe-js": "^1.54.1",
"@types/crypto-js": "^4.1.1",
@@ -40,6 +39,7 @@
"eslint-config-next": "13.4.9",
"framer-motion": "^10.16.4",
"jsdom": "^22.1.0",
+ "lottie-web": "^5.12.2",
"micro": "^10.0.1",
"next": "13.4.12",
"next-auth": "^4.22.1",
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.ts b/pages/api/v1/public/collections/[id].ts
similarity index 59%
rename from pages/api/v1/public/collections.ts
rename to pages/api/v1/public/collections/[id].ts
index 5f6b808..04178f8 100644
--- a/pages/api/v1/public/collections.ts
+++ b/pages/api/v1/public/collections/[id].ts
@@ -1,18 +1,18 @@
-import getCollection from "@/lib/api/controllers/public/getCollection";
+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
) {
- if (!req?.query?.body) {
+ if (!req?.query?.id) {
return res
.status(401)
.json({ response: "Please choose a valid collection." });
}
if (req.method === "GET") {
- const collection = await getCollection(req?.query?.body as string);
+ const collection = await getPublicCollection(Number(req?.query?.id));
return res
.status(collection.status)
.json({ response: collection.response });
diff --git a/pages/api/v1/public/collections/links/index.ts b/pages/api/v1/public/collections/links/index.ts
new file mode 100644
index 0000000..dd55179
--- /dev/null
+++ b/pages/api/v1/public/collections/links/index.ts
@@ -0,0 +1,41 @@
+import getPublicLinksUnderCollection from "@/lib/api/controllers/public/links/getPublicLinksUnderCollection";
+import { LinkRequestQuery } from "@/types/global";
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default async function collections(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ if (req.method === "GET") {
+ // Convert the type of the request query to "LinkRequestQuery"
+ const convertedData: Omit = {
+ sort: Number(req.query.sort as string),
+ cursor: req.query.cursor ? Number(req.query.cursor as string) : undefined,
+ collectionId: req.query.collectionId
+ ? Number(req.query.collectionId as string)
+ : undefined,
+ pinnedOnly: req.query.pinnedOnly
+ ? req.query.pinnedOnly === "true"
+ : undefined,
+ searchQueryString: req.query.searchQueryString
+ ? (req.query.searchQueryString as string)
+ : undefined,
+ searchByName: req.query.searchByName === "true" ? true : undefined,
+ searchByUrl: req.query.searchByUrl === "true" ? true : undefined,
+ searchByDescription:
+ req.query.searchByDescription === "true" ? true : undefined,
+ searchByTextContent:
+ req.query.searchByTextContent === "true" ? true : undefined,
+ searchByTags: req.query.searchByTags === "true" ? true : undefined,
+ };
+
+ if (!convertedData.collectionId) {
+ return res
+ .status(400)
+ .json({ response: "Please choose a valid collection." });
+ }
+
+ const links = await getPublicLinksUnderCollection(convertedData);
+ return res.status(links.status).json({ response: links.response });
+ }
+}
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/api/v1/public/users/[id].ts b/pages/api/v1/public/users/[id].ts
index 5126740..f5b66ed 100644
--- a/pages/api/v1/public/users/[id].ts
+++ b/pages/api/v1/public/users/[id].ts
@@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
-import getPublicUserById from "@/lib/api/controllers/public/users/getPublicUserById";
+import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser";
import { getToken } from "next-auth/jwt";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
@@ -12,7 +12,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
const isId = lookupId.split("").every((e) => Number.isInteger(parseInt(e)));
if (req.method === "GET") {
- const users = await getPublicUserById(lookupId, isId, requestingId);
+ const users = await getPublicUser(lookupId, isId, requestingId);
return res.status(users.status).json({ response: users.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 80d03f2..eea44bc 100644
--- a/pages/public/collections/[id].tsx
+++ b/pages/public/collections/[id].tsx
@@ -1,12 +1,26 @@
"use client";
-import LinkCard from "@/components/PublicPage/LinkCard";
-import useDetectPageBottom from "@/hooks/useDetectPageBottom";
+import PublicLinkCard from "@/components/PublicPage/PublicLinkCard";
import getPublicCollectionData from "@/lib/client/getPublicCollectionData";
-import { PublicCollectionIncludingLinks } from "@/types/global";
+import { CollectionIncludingMembersAndLinkCount, Sort } from "@/types/global";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { motion, Variants } from "framer-motion";
import Head from "next/head";
+import useLinks from "@/hooks/useLinks";
+import useLinkStore from "@/store/links";
+import ProfilePhoto from "@/components/ProfilePhoto";
+import useModalStore from "@/store/modals";
+import ModalManagement from "@/components/ModalManagement";
+import ToggleDarkMode from "@/components/ToggleDarkMode";
+import { useTheme } from "next-themes";
+import getPublicUserData from "@/lib/client/getPublicUserData";
+import Image from "next/image";
+import Link from "next/link";
+import PublicSearchBar from "@/components/PublicPage/PublicSearchBar";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faFilter, faSort } from "@fortawesome/free-solid-svg-icons";
+import FilterSearchDropdown from "@/components/FilterSearchDropdown";
+import SortDropdown from "@/components/SortDropdown";
const cardVariants: Variants = {
offscreen: {
@@ -23,89 +37,242 @@ const cardVariants: Variants = {
};
export default function PublicCollections() {
+ const { links } = useLinkStore();
+ const { modal, setModal } = useModalStore();
+
+ useEffect(() => {
+ modal
+ ? (document.body.style.overflow = "hidden")
+ : (document.body.style.overflow = "auto");
+ }, [modal]);
+
+ const { theme } = useTheme();
+
const router = useRouter();
- const { reachedBottom, setReachedBottom } = useDetectPageBottom();
- const [data, setData] = useState
();
+ const [collectionOwner, setCollectionOwner] = useState({
+ id: null,
+ name: "",
+ username: "",
+ image: "",
+ });
- document.body.style.background = "white";
+ const [searchFilter, setSearchFilter] = useState({
+ name: true,
+ url: true,
+ description: true,
+ textContent: true,
+ tags: true,
+ });
+
+ const [filterDropdown, setFilterDropdown] = useState(false);
+ const [sortDropdown, setSortDropdown] = useState(false);
+ const [sortBy, setSortBy] = useState(Sort.DateNewestFirst);
+
+ useLinks({
+ sort: sortBy,
+ searchQueryString: router.query.q
+ ? decodeURIComponent(router.query.q as string)
+ : undefined,
+ searchByName: searchFilter.name,
+ searchByUrl: searchFilter.url,
+ searchByDescription: searchFilter.description,
+ searchByTextContent: searchFilter.textContent,
+ searchByTags: searchFilter.tags,
+ });
+
+ const [collection, setCollection] =
+ useState();
useEffect(() => {
if (router.query.id) {
- getPublicCollectionData(
- Number(router.query.id),
- data as PublicCollectionIncludingLinks,
- setData
- );
+ getPublicCollectionData(Number(router.query.id), setCollection);
}
-
- // document
- // .querySelector("body")
- // ?.classList.add(
- // "bg-gradient-to-br",
- // "from-slate-50",
- // "to-sky-50",
- // "min-h-screen"
- // );
}, []);
useEffect(() => {
- if (reachedBottom && router.query.id) {
- getPublicCollectionData(
- Number(router.query.id),
- data as PublicCollectionIncludingLinks,
- setData
- );
- }
+ const fetchOwner = async () => {
+ if (collection) {
+ const owner = await getPublicUserData(collection.ownerId as number);
+ setCollectionOwner(owner);
+ }
+ };
- setReachedBottom(false);
- }, [reachedBottom]);
+ fetchOwner();
+ }, [collection]);
- return data ? (
-
- {data ? (
+ return collection ? (
+
+
+
+ {collection ? (
-
{data.name} | Linkwarden
+
{collection.name} | Linkwarden
) : undefined}
-
-
{data.name}
+
+
+
+ {collection.name}
+
+
+
+
+
+
+
+
- {data.description && (
- <>
-
-
{data.description}
- >
- )}
-
-
-
- {data?.links?.map((e, i) => {
- return (
-
+
+
+ setModal({
+ modal: "COLLECTION",
+ state: true,
+ method: "VIEW_TEAM",
+ isOwner: false,
+ active: collection,
+ defaultIndex: 0,
+ })
+ }
+ className="hover:opacity-80 duration-100 flex justify-center sm:justify-end items-start w-fit cursor-pointer"
>
-
-
-
-
- );
- })}
-
+ {collectionOwner.id ? (
+
+ ) : undefined}
+ {collection.members
+ .sort((a, b) => (a.userId as number) - (b.userId as number))
+ .map((e, i) => {
+ return (
+
+ );
+ })
+ .slice(0, 3)}
+ {collection?.members.length &&
+ collection.members.length - 3 > 0 ? (
+
+ +{collection?.members?.length - 3}
+
+ ) : null}
- {/*
+
+ By {collectionOwner.name} and {collection.members.length}{" "}
+ others.
+
+
+
+
+
+
{collection.description}
+
+
+
+
+
+
+
+
+
+
setFilterDropdown(!filterDropdown)}
+ id="filter-dropdown"
+ className="inline-flex rounded-md cursor-pointer hover:bg-neutral-500 hover:bg-opacity-40 duration-100 p-1"
+ >
+
+
+
+ {filterDropdown ? (
+
+ ) : null}
+
+
+
+
setSortDropdown(!sortDropdown)}
+ id="sort-dropdown"
+ className="inline-flex rounded-md cursor-pointer hover:bg-neutral-500 hover:bg-opacity-40 duration-100 p-1"
+ >
+
+
+
+ {sortDropdown ? (
+
setSortDropdown(!sortDropdown)}
+ />
+ ) : null}
+
+
+
+
+
+ {links
+ ?.filter((e) => e.collectionId === Number(router.query.id))
+ .map((e, i) => {
+ return (
+
+
+
+
+
+ );
+ })}
+
+
+ {/*
List created with Linkwarden.
-
*/}
+ */}
+
+
) : (
<>>
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/pages/search/index.tsx b/pages/search.tsx
similarity index 100%
rename from pages/search/index.tsx
rename to pages/search.tsx
diff --git a/pages/tags/[id].tsx b/pages/tags/[id].tsx
index 466c3bb..5859c2a 100644
--- a/pages/tags/[id].tsx
+++ b/pages/tags/[id].tsx
@@ -11,10 +11,9 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "next/router";
import { FormEvent, useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout";
-import { Tag } from "@prisma/client";
import useTagStore from "@/store/tags";
import SortDropdown from "@/components/SortDropdown";
-import { Sort } from "@/types/global";
+import { Sort, TagIncludingLinkCount } from "@/types/global";
import useLinks from "@/hooks/useLinks";
import Dropdown from "@/components/Dropdown";
import { toast } from "react-hot-toast";
@@ -33,7 +32,7 @@ export default function Index() {
const [renameTag, setRenameTag] = useState(false);
const [newTagName, setNewTagName] = useState();
- const [activeTag, setActiveTag] = useState();
+ const [activeTag, setActiveTag] = useState();
useLinks({ tagId: Number(router.query.id), sort: sortBy });
diff --git a/public/icon.png b/public/icon.png
new file mode 100644
index 0000000..ac6d7e9
Binary files /dev/null and b/public/icon.png differ
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/store/modals.ts b/store/modals.ts
index 71ecef0..f709d70 100644
--- a/store/modals.ts
+++ b/store/modals.ts
@@ -39,6 +39,14 @@ type Modal =
active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
+ | {
+ modal: "COLLECTION";
+ state: boolean;
+ method: "VIEW_TEAM";
+ isOwner?: boolean;
+ active?: CollectionIncludingMembersAndLinkCount;
+ defaultIndex?: number;
+ }
| null;
type ModalsStore = {
@@ -46,11 +54,11 @@ type ModalsStore = {
setModal: (modal: Modal) => void;
};
-const useLocalSettingsStore = create((set) => ({
+const useModalStore = create((set) => ({
modal: null,
setModal: (modal: Modal) => {
set({ modal });
},
}));
-export default useLocalSettingsStore;
+export default useModalStore;
diff --git a/store/tags.ts b/store/tags.ts
index a27b859..6778152 100644
--- a/store/tags.ts
+++ b/store/tags.ts
@@ -1,5 +1,5 @@
import { create } from "zustand";
-import { Tag } from "@prisma/client";
+import { TagIncludingLinkCount } from "@/types/global";
type ResponseObject = {
ok: boolean;
@@ -7,9 +7,9 @@ type ResponseObject = {
};
type TagStore = {
- tags: Tag[];
+ tags: TagIncludingLinkCount[];
setTags: () => void;
- updateTag: (tag: Tag) => Promise;
+ updateTag: (tag: TagIncludingLinkCount) => Promise;
removeTag: (tagId: number) => Promise;
};
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;
diff --git a/types/global.ts b/types/global.ts
index 26058cf..2af3766 100644
--- a/types/global.ts
+++ b/types/global.ts
@@ -37,6 +37,10 @@ export interface CollectionIncludingMembersAndLinkCount
members: Member[];
}
+export interface TagIncludingLinkCount extends Tag {
+ _count?: { links: number };
+}
+
export interface AccountSettings extends User {
newPassword?: string;
whitelistedUsers: string[];
diff --git a/yarn.lock b/yarn.lock
index 024191a..57493d9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -919,11 +919,6 @@
dependencies:
glob "7.1.7"
-"@next/font@13.4.9":
- version "13.4.9"
- resolved "https://registry.yarnpkg.com/@next/font/-/font-13.4.9.tgz#5540e69a1a5fbd1113d622a89cdd21c0ab3906c8"
- integrity sha512-aR0XEyd1cxqaKuelQFDGwUBYV0wyZfJTNiRoSk1XsECTyMhiSMmCOY7yOPMuPlw+6cctca0GyZXGGFb5EVhiRw==
-
"@next/swc-darwin-arm64@13.4.12":
version "13.4.12"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.12.tgz#326c830b111de8a1a51ac0cbc3bcb157c4c4f92c"
@@ -3627,6 +3622,11 @@ loose-envify@^1.1.0, loose-envify@^1.4.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
+lottie-web@^5.12.2:
+ version "5.12.2"
+ resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.12.2.tgz#579ca9fe6d3fd9e352571edd3c0be162492f68e5"
+ integrity sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg==
+
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"