diff --git a/components/DashboardItem.tsx b/components/DashboardItem.tsx index d891398..3de882e 100644 --- a/components/DashboardItem.tsx +++ b/components/DashboardItem.tsx @@ -20,7 +20,7 @@ export default function dashboardItem({ name, value, icon }: Props) {

{name}

-

+

{value}

diff --git a/components/FilterSearchDropdown.tsx b/components/FilterSearchDropdown.tsx index 0b32bee..90648e0 100644 --- a/components/FilterSearchDropdown.tsx +++ b/components/FilterSearchDropdown.tsx @@ -56,7 +56,7 @@ export default function FilterSearchDropdown({ } /> setSearchFilter({ diff --git a/components/LinkSidebar.tsx b/components/LinkSidebar.tsx index 6ffc2f5..63ddb0a 100644 --- a/components/LinkSidebar.tsx +++ b/components/LinkSidebar.tsx @@ -20,7 +20,7 @@ type Props = { onClick?: Function; }; -export default function SettingsSidebar({ className, onClick }: Props) { +export default function LinkSidebar({ className, onClick }: Props) { const session = useSession(); const userId = session.data?.user.id; diff --git a/components/Modal/Collection/CollectionInfo.tsx b/components/Modal/Collection/CollectionInfo.tsx index f0e71b2..962e9ee 100644 --- a/components/Modal/Collection/CollectionInfo.tsx +++ b/components/Modal/Collection/CollectionInfo.tsx @@ -18,7 +18,7 @@ type Props = { SetStateAction >; collection: CollectionIncludingMembersAndLinkCount; - method: "CREATE" | "UPDATE"; + method: "CREATE" | "UPDATE" | "VIEW_TEAM"; }; export default function CollectionInfo({ diff --git a/components/Modal/Collection/ViewTeam.tsx b/components/Modal/Collection/ViewTeam.tsx new file mode 100644 index 0000000..5356870 --- /dev/null +++ b/components/Modal/Collection/ViewTeam.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCrown } from "@fortawesome/free-solid-svg-icons"; +import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; +import ProfilePhoto from "@/components/ProfilePhoto"; +import getPublicUserData from "@/lib/client/getPublicUserData"; + +type Props = { + collection: CollectionIncludingMembersAndLinkCount; +}; + +export default function ViewTeam({ collection }: Props) { + const [collectionOwner, setCollectionOwner] = useState({ + id: null, + name: "", + username: "", + image: "", + }); + + useEffect(() => { + const fetchOwner = async () => { + const owner = await getPublicUserData(collection.ownerId as number); + setCollectionOwner(owner); + }; + + fetchOwner(); + }, []); + + return ( +
+

Team

+ +

Here's all the members that are collaborating in this collection.

+ +
+
+ +
+
+

+ {collectionOwner.name} +

+
+ + Admin +
+
+

+ @{collectionOwner.username} +

+
+
+
+ + {collection?.members[0]?.user && ( + <> +
+ {collection.members + .sort((a, b) => (a.userId as number) - (b.userId as number)) + .map((e, i) => { + return ( +
+
+ +
+

+ {e.user.name} +

+

+ @{e.user.username} +

+
+
+
+ ); + })} +
+ + )} +
+ ); +} diff --git a/components/Modal/Collection/index.tsx b/components/Modal/Collection/index.tsx index 747a742..a535b30 100644 --- a/components/Modal/Collection/index.tsx +++ b/components/Modal/Collection/index.tsx @@ -4,6 +4,7 @@ import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import TeamManagement from "./TeamManagement"; import { useState } from "react"; import DeleteCollection from "./DeleteCollection"; +import ViewTeam from "./ViewTeam"; type Props = | { @@ -21,6 +22,14 @@ type Props = isOwner: boolean; className?: string; defaultIndex?: number; + } + | { + toggleCollectionModal: Function; + activeCollection: CollectionIncludingMembersAndLinkCount; + method: "VIEW_TEAM"; + isOwner: boolean; + className?: string; + defaultIndex?: number; }; export default function CollectionModal({ @@ -46,14 +55,25 @@ export default function CollectionModal({
{method === "CREATE" && ( -

+

New Collection

)} - - {method === "UPDATE" && ( - <> - {isOwner && ( + {method !== "VIEW_TEAM" && ( + + {method === "UPDATE" && ( + <> + {isOwner && ( + + selected + ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none" + : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" + } + > + Collection Info + + )} selected @@ -61,30 +81,21 @@ export default function CollectionModal({ : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" } > - Collection Info + {isOwner ? "Share & Collaborate" : "View Team"} - )} - - selected - ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none" - : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" - } - > - {isOwner ? "Share & Collaborate" : "View Team"} - - - selected - ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none" - : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" - } - > - {isOwner ? "Delete Collection" : "Leave Collection"} - - - )} - + + selected + ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none" + : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" + } + > + {isOwner ? "Delete Collection" : "Leave Collection"} + + + )} + + )} {(isOwner || method === "CREATE") && ( @@ -115,6 +126,14 @@ export default function CollectionModal({ )} + + {method === "VIEW_TEAM" && ( + <> + + + + + )}
diff --git a/components/Modal/Link/PreservedFormats.tsx b/components/Modal/Link/PreservedFormats.tsx index 1a6712b..0cd21c1 100644 --- a/components/Modal/Link/PreservedFormats.tsx +++ b/components/Modal/Link/PreservedFormats.tsx @@ -27,7 +27,14 @@ export default function PreservedFormats() { useEffect(() => { let interval: NodeJS.Timer | undefined; if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") { - interval = setInterval(() => getLink(link.id as number), 5000); + let isPublicRoute = router.pathname.startsWith("/public") + ? true + : undefined; + + interval = setInterval( + () => getLink(link.id as number, isPublicRoute), + 5000 + ); } else { if (interval) { clearInterval(interval); diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 4a91fb5..57af684 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -6,12 +6,13 @@ import Dropdown from "@/components/Dropdown"; import ClickAwayHandler from "@/components/ClickAwayHandler"; import Sidebar from "@/components/Sidebar"; import { useRouter } from "next/router"; -import Search from "@/components/Search"; +import SearchBar from "@/components/SearchBar"; import useAccountStore from "@/store/account"; import ProfilePhoto from "@/components/ProfilePhoto"; import useModalStore from "@/store/modals"; import { useTheme } from "next-themes"; import useWindowDimensions from "@/hooks/useWindowDimensions"; +import ToggleDarkMode from "./ToggleDarkMode"; export default function Navbar() { const { setModal } = useModalStore(); @@ -56,7 +57,7 @@ export default function Navbar() { > - +
{ @@ -76,6 +77,9 @@ export default function Navbar() { New Link
+ + +
setImage("")} className={`h-10 w-10 bg-sky-600 dark:bg-sky-600 shadow rounded-full aspect-square border border-slate-200 dark:border-neutral-700 ${ className || "" }`} diff --git a/components/PublicPage/LinkCard.tsx b/components/PublicPage/LinkCard.tsx deleted file mode 100644 index 3be1972..0000000 --- a/components/PublicPage/LinkCard.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { faChevronRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Image from "next/image"; -import { Link as LinkType, Tag } from "@prisma/client"; -import isValidUrl from "@/lib/client/isValidUrl"; -import unescapeString from "@/lib/client/unescapeString"; - -interface LinksIncludingTags extends LinkType { - tags: Tag[]; -} - -type Props = { - link: LinksIncludingTags; - count: number; -}; - -export default function LinkCard({ link, count }: Props) { - const url = isValidUrl(link.url) ? new URL(link.url) : undefined; - - const formattedDate = new Date( - link.createdAt as unknown as string - ).toLocaleString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); - - return ( - -
- {url && ( - <> - { - const target = e.target as HTMLElement; - target.style.display = "none"; - }} - /> - { - const target = e.target as HTMLElement; - target.style.display = "none"; - }} - /> - - )} -
-
-
-

{count + 1}

-

- {unescapeString(link.name || link.description)} -

-
- -

- {unescapeString(link.description)} -

-
-
- {link.tags.map((e, i) => ( -

- {e.name} -

- ))} -
-
-
-

{formattedDate}

-
-

{url ? url.host : link.url}

-
-
-
-
- -
-
-
-
- ); -} diff --git a/components/PublicPage/PublicLinkCard.tsx b/components/PublicPage/PublicLinkCard.tsx new file mode 100644 index 0000000..3d6b01d --- /dev/null +++ b/components/PublicPage/PublicLinkCard.tsx @@ -0,0 +1,96 @@ +import { faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Image from "next/image"; +import { Link as LinkType, Tag } from "@prisma/client"; +import isValidUrl from "@/lib/client/isValidUrl"; +import unescapeString from "@/lib/client/unescapeString"; +import { TagIncludingLinkCount } from "@/types/global"; +import Link from "next/link"; + +interface LinksIncludingTags extends LinkType { + tags: TagIncludingLinkCount[]; +} + +type Props = { + link: LinksIncludingTags; + count: number; +}; + +export default function LinkCard({ link, count }: Props) { + const url = isValidUrl(link.url) ? new URL(link.url) : undefined; + + const formattedDate = new Date( + link.createdAt as unknown as string + ).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + + return ( +
+
+
+
+

+ {url && ( + { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + )} + {unescapeString(link.name || link.description)} +

+
+ +
+
+ {link.tags.map((e, i) => ( + + {e.name} + + ))} +
+
+
+

{formattedDate}

+

·

+ + {url ? url.host : link.url} + +
+
+ {unescapeString(link.description)}{" "} + +

Read

+ + +
+
+
+
+ ); +} diff --git a/components/PublicPage/PublicSearchBar.tsx b/components/PublicPage/PublicSearchBar.tsx new file mode 100644 index 0000000..525daa3 --- /dev/null +++ b/components/PublicPage/PublicSearchBar.tsx @@ -0,0 +1,59 @@ +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { toast } from "react-hot-toast"; + +type Props = { + placeHolder?: string; +}; + +export default function PublicSearchBar({ placeHolder }: Props) { + const router = useRouter(); + + const [searchQuery, setSearchQuery] = useState(""); + + useEffect(() => { + router.query.q + ? setSearchQuery(decodeURIComponent(router.query.q as string)) + : setSearchQuery(""); + }, [router.query.q]); + + return ( +
+ + + { + e.target.value.includes("%") && + toast.error("The search query should not contain '%'."); + setSearchQuery(e.target.value.replace("%", "")); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (!searchQuery) { + return router.push("/public/collections/" + router.query.id); + } + + return router.push( + "/public/collections/" + + router.query.id + + "?q=" + + encodeURIComponent(searchQuery || "") + ); + } + }} + className="border text-sm border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-8 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800" + /> +
+ ); +} diff --git a/components/Search.tsx b/components/SearchBar.tsx similarity index 82% rename from components/Search.tsx rename to components/SearchBar.tsx index 842b205..df0b9a4 100644 --- a/components/Search.tsx +++ b/components/SearchBar.tsx @@ -4,24 +4,17 @@ import { useState } from "react"; import { useRouter } from "next/router"; import { toast } from "react-hot-toast"; -export default function Search() { +export default function SearchBar() { const router = useRouter(); - const routeQuery = router.query.query; + const routeQuery = router.query.q; const [searchQuery, setSearchQuery] = useState( routeQuery ? decodeURIComponent(routeQuery as string) : "" ); - const [searchBox, setSearchBox] = useState( - router.pathname.startsWith("/search") || false - ); - return ( -
setSearchBox(true)} - > +
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 366c28c..78a31a2 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -176,6 +176,9 @@ export default function Sidebar({ className }: { className?: string }) { className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300" /> ) : undefined} +
+ {e._count?.links} +
); @@ -235,6 +238,9 @@ export default function Sidebar({ className }: { className?: string }) {

{e.name}

+
+ {e._count?.links} +
); diff --git a/components/ToggleDarkMode.tsx b/components/ToggleDarkMode.tsx index 0ba36b8..cff56c0 100644 --- a/components/ToggleDarkMode.tsx +++ b/components/ToggleDarkMode.tsx @@ -2,7 +2,11 @@ import { useTheme } from "next-themes"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; -export default function ToggleDarkMode() { +type Props = { + className?: string; +}; + +export default function ToggleDarkMode({ className }: Props) { const { theme, setTheme } = useTheme(); const handleToggle = () => { @@ -15,15 +19,13 @@ export default function ToggleDarkMode() { return (
-
- -
+
); } diff --git a/docker-compose.yml b/docker-compose.yml index 2a400df..f1ff559 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.5" services: postgres: - image: postgres + image: postgres:16-alpine env_file: .env restart: always volumes: diff --git a/hooks/useLinks.tsx b/hooks/useLinks.tsx index 60f43db..77e4dce 100644 --- a/hooks/useLinks.tsx +++ b/hooks/useLinks.tsx @@ -50,13 +50,17 @@ export default function useLinks( .join("&"); }; - const queryString = buildQueryString(params); + let queryString = buildQueryString(params); - const response = await fetch( - `/api/v1/${ - router.asPath === "/dashboard" ? "dashboard" : "links" - }?${queryString}` - ); + let basePath; + + if (router.pathname === "/dashboard") basePath = "/api/v1/dashboard"; + else if (router.pathname.startsWith("/public/collections/[id]")) { + queryString = queryString + "&collectionId=" + router.query.id; + basePath = "/api/v1/public/collections/links"; + } else basePath = "/api/v1/links"; + + const response = await fetch(`${basePath}?${queryString}`); const data = await response.json(); diff --git a/layouts/LinkLayout.tsx b/layouts/LinkLayout.tsx index b2c135e..398acda 100644 --- a/layouts/LinkLayout.tsx +++ b/layouts/LinkLayout.tsx @@ -5,8 +5,7 @@ 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 { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import useWindowDimensions from "@/hooks/useWindowDimensions"; import { faPen, @@ -66,22 +65,22 @@ export default function LinkLayout({ children }: Props) { const [link, setLink] = useState(); useEffect(() => { - if (links) setLink(links.find((e) => e.id === Number(router.query.id))); + if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id))); }, [links]); useEffect(() => { if (link) setLinkCollection(collections.find((e) => e.id === link?.collection.id)); - }, [link]); + }, [link, collections]); return ( <>
-
+ {/*
-
+
*/}
@@ -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() { >