Merge pull request #308 from linkwarden/improved-public-page

Improved public page
This commit is contained in:
Daniel 2023-11-19 16:43:24 +03:30 committed by GitHub
commit 30c9c86e22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1266 additions and 404 deletions

View File

@ -20,7 +20,7 @@ export default function dashboardItem({ name, value, icon }: Props) {
<p className="text-gray-500 dark:text-gray-400 text-sm tracking-wider"> <p className="text-gray-500 dark:text-gray-400 text-sm tracking-wider">
{name} {name}
</p> </p>
<p className="font-thin text-6xl text-sky-500 dark:text-sky-500"> <p className="font-thin text-6xl text-sky-500 dark:text-sky-500 mt-2">
{value} {value}
</p> </p>
</div> </div>

View File

@ -56,7 +56,7 @@ export default function FilterSearchDropdown({
} }
/> />
<Checkbox <Checkbox
label="Text Content" label="Full Content"
state={searchFilter.textContent} state={searchFilter.textContent}
onClick={() => onClick={() =>
setSearchFilter({ setSearchFilter({

View File

@ -20,7 +20,7 @@ type Props = {
onClick?: Function; onClick?: Function;
}; };
export default function SettingsSidebar({ className, onClick }: Props) { export default function LinkSidebar({ className, onClick }: Props) {
const session = useSession(); const session = useSession();
const userId = session.data?.user.id; const userId = session.data?.user.id;

View File

@ -18,7 +18,7 @@ type Props = {
SetStateAction<CollectionIncludingMembersAndLinkCount> SetStateAction<CollectionIncludingMembersAndLinkCount>
>; >;
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
method: "CREATE" | "UPDATE"; method: "CREATE" | "UPDATE" | "VIEW_TEAM";
}; };
export default function CollectionInfo({ export default function CollectionInfo({

View File

@ -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 (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<p className="ml-10 text-xl font-thin">Team</p>
<p>Here's all the members that are collaborating in this collection.</p>
<div
className="relative border px-2 rounded-md border-sky-100 dark:border-neutral-700 flex min-h-[4rem] gap-2 justify-between"
title={`'@${collectionOwner.username}' is the owner of this collection.`}
>
<div className="flex items-center gap-2 w-full">
<ProfilePhoto
src={collectionOwner.image ? collectionOwner.image : undefined}
className="border-[3px]"
/>
<div className="w-full">
<div className="flex items-center gap-1 w-full justify-between">
<p className="text-sm font-bold text-black dark:text-white">
{collectionOwner.name}
</p>
<div className="flex text-xs gap-1 items-center">
<FontAwesomeIcon
icon={faCrown}
className="w-3 h-3 text-yellow-500"
/>
Admin
</div>
</div>
<p className="text-gray-500 dark:text-gray-300">
@{collectionOwner.username}
</p>
</div>
</div>
</div>
{collection?.members[0]?.user && (
<>
<div className="flex flex-col gap-3 rounded-md">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<div
key={i}
className="relative border p-2 rounded-md border-sky-100 dark:border-neutral-700 flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
>
<div className="flex items-center gap-2">
<ProfilePhoto
src={e.user.image ? e.user.image : undefined}
className="border-[3px]"
/>
<div>
<p className="text-sm font-bold text-black dark:text-white">
{e.user.name}
</p>
<p className="text-gray-500 dark:text-gray-300">
@{e.user.username}
</p>
</div>
</div>
</div>
);
})}
</div>
</>
)}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import TeamManagement from "./TeamManagement"; import TeamManagement from "./TeamManagement";
import { useState } from "react"; import { useState } from "react";
import DeleteCollection from "./DeleteCollection"; import DeleteCollection from "./DeleteCollection";
import ViewTeam from "./ViewTeam";
type Props = type Props =
| { | {
@ -21,6 +22,14 @@ type Props =
isOwner: boolean; isOwner: boolean;
className?: string; className?: string;
defaultIndex?: number; defaultIndex?: number;
}
| {
toggleCollectionModal: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
method: "VIEW_TEAM";
isOwner: boolean;
className?: string;
defaultIndex?: number;
}; };
export default function CollectionModal({ export default function CollectionModal({
@ -46,10 +55,11 @@ export default function CollectionModal({
<div className={className}> <div className={className}>
<Tab.Group defaultIndex={defaultIndex}> <Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && ( {method === "CREATE" && (
<p className="text-xl text-black dark:text-white text-center"> <p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
New Collection New Collection
</p> </p>
)} )}
{method !== "VIEW_TEAM" && (
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-black dark:text-white"> <Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-black dark:text-white">
{method === "UPDATE" && ( {method === "UPDATE" && (
<> <>
@ -85,6 +95,7 @@ export default function CollectionModal({
</> </>
)} )}
</Tab.List> </Tab.List>
)}
<Tab.Panels> <Tab.Panels>
{(isOwner || method === "CREATE") && ( {(isOwner || method === "CREATE") && (
<Tab.Panel> <Tab.Panel>
@ -115,6 +126,14 @@ export default function CollectionModal({
</Tab.Panel> </Tab.Panel>
</> </>
)} )}
{method === "VIEW_TEAM" && (
<>
<Tab.Panel>
<ViewTeam collection={collection} />
</Tab.Panel>
</>
)}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@ -27,7 +27,14 @@ export default function PreservedFormats() {
useEffect(() => { useEffect(() => {
let interval: NodeJS.Timer | undefined; let interval: NodeJS.Timer | undefined;
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") { 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 { } else {
if (interval) { if (interval) {
clearInterval(interval); clearInterval(interval);

View File

@ -6,12 +6,13 @@ import Dropdown from "@/components/Dropdown";
import ClickAwayHandler from "@/components/ClickAwayHandler"; import ClickAwayHandler from "@/components/ClickAwayHandler";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Search from "@/components/Search"; import SearchBar from "@/components/SearchBar";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto"; import ProfilePhoto from "@/components/ProfilePhoto";
import useModalStore from "@/store/modals"; import useModalStore from "@/store/modals";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import useWindowDimensions from "@/hooks/useWindowDimensions"; import useWindowDimensions from "@/hooks/useWindowDimensions";
import ToggleDarkMode from "./ToggleDarkMode";
export default function Navbar() { export default function Navbar() {
const { setModal } = useModalStore(); const { setModal } = useModalStore();
@ -56,7 +57,7 @@ export default function Navbar() {
> >
<FontAwesomeIcon icon={faBars} className="w-5 h-5" /> <FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div> </div>
<Search /> <SearchBar />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
onClick={() => { onClick={() => {
@ -76,6 +77,9 @@ export default function Navbar() {
New Link New Link
</span> </span>
</div> </div>
<ToggleDarkMode className="sm:flex hidden" />
<div className="relative"> <div className="relative">
<div <div
className="flex gap-1 group sm:hover:bg-slate-200 sm:hover:dark:bg-neutral-700 sm:hover:p-1 sm:hover:pr-2 duration-100 h-10 rounded-full items-center w-fit cursor-pointer" className="flex gap-1 group sm:hover:bg-slate-200 sm:hover:dark:bg-neutral-700 sm:hover:p-1 sm:hover:pr-2 duration-100 h-10 rounded-full items-center w-fit cursor-pointer"

View File

@ -38,6 +38,7 @@ export default function ProfilePhoto({ src, className, priority }: Props) {
width={112} width={112}
priority={priority} priority={priority}
draggable={false} draggable={false}
onError={() => 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={`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 || "" className || ""
}`} }`}

View File

@ -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 (
<a href={link.url} target="_blank" rel="noreferrer" className="rounded-3xl">
<div className="border border-solid border-sky-100 bg-gradient-to-tr from-slate-200 from-10% to-gray-50 via-20% shadow-md sm:hover:shadow-none duration-100 rounded-3xl cursor-pointer p-5 flex items-start relative gap-5 sm:gap-10 group/item">
{url && (
<>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={42}
height={42}
alt=""
className="select-none mt-3 z-10 rounded-md shadow border-[3px] border-white bg-white"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={80}
height={80}
alt=""
className="blur-sm absolute left-2 opacity-40 select-none hidden sm:block"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
</>
)}
<div className="flex justify-between items-center gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between">
<div className="flex items-baseline gap-1">
<p className="text-xs text-gray-500">{count + 1}</p>
<p className="text-lg text-black">
{unescapeString(link.name || link.description)}
</p>
</div>
<p className="text-gray-500 text-sm font-medium">
{unescapeString(link.description)}
</p>
<div className="flex gap-3 items-center flex-wrap my-3">
<div className="flex gap-1 items-center flex-wrap mt-1">
{link.tags.map((e, i) => (
<p
key={i}
className="px-2 py-1 bg-sky-200 text-black text-xs rounded-3xl cursor-pointer truncate max-w-[10rem]"
>
{e.name}
</p>
))}
</div>
</div>
<div className="flex gap-2 items-center flex-wrap mt-2">
<p className="text-gray-500">{formattedDate}</p>
<div className="text-black flex items-center gap-1">
<p>{url ? url.host : link.url}</p>
</div>
</div>
</div>
<div className="hidden sm:group-hover/item:block duration-100 text-slate-500">
<FontAwesomeIcon
icon={faChevronRight}
className="w-7 h-7 slide-right-with-fade"
/>
</div>
</div>
</div>
</a>
);
}

View File

@ -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 (
<div className="border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-lg p-3 flex items-start relative gap-3 group/item">
<div className="flex justify-between items-end gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-center gap-2">
<p className="text-2xl">
{url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={30}
height={30}
alt=""
className="select-none z-10 rounded-md shadow border-[1px] border-white bg-white float-left mr-2"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
)}
{unescapeString(link.name || link.description)}
</p>
</div>
<div className="flex gap-3 items-center flex-wrap my-2">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/public/collections/20?q=" + e.name}
key={i}
className="px-2 text-xs rounded-md border border-black dark:border-white truncate max-w-[10rem] hover:opacity-50 duration-100"
>
{e.name}
</Link>
))}
</div>
</div>
<div className="flex gap-1 items-center flex-wrap text-sm text-gray-500 dark:text-gray-300">
<p>{formattedDate}</p>
<p>·</p>
<Link
href={url ? url.href : link.url}
target="_blank"
className="hover:opacity-50 duration-100 truncate w-52 sm:w-fit"
title={url ? url.href : link.url}
>
{url ? url.host : link.url}
</Link>
</div>
<div className="w-full">
{unescapeString(link.description)}{" "}
<Link
href={`/public/links/${link.id}`}
className="flex gap-1 items-center flex-wrap text-sm text-gray-500 dark:text-gray-300 hover:opacity-50 duration-100 min-w-fit float-right mt-1 ml-2"
>
<p>Read</p>
<FontAwesomeIcon
icon={faChevronRight}
className="w-3 h-3 mt-[0.15rem]"
/>
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<string>("");
useEffect(() => {
router.query.q
? setSearchQuery(decodeURIComponent(router.query.q as string))
: setSearchQuery("");
}, [router.query.q]);
return (
<div className="flex items-center relative group">
<label
htmlFor="search-box"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md text-sky-500 dark:text-sky-500"
>
<FontAwesomeIcon icon={faMagnifyingGlass} className="w-4 h-4" />
</label>
<input
id="search-box"
type="text"
placeholder={placeHolder}
value={searchQuery}
onChange={(e) => {
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"
/>
</div>
);
}

View File

@ -4,24 +4,17 @@ import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
export default function Search() { export default function SearchBar() {
const router = useRouter(); const router = useRouter();
const routeQuery = router.query.query; const routeQuery = router.query.q;
const [searchQuery, setSearchQuery] = useState( const [searchQuery, setSearchQuery] = useState(
routeQuery ? decodeURIComponent(routeQuery as string) : "" routeQuery ? decodeURIComponent(routeQuery as string) : ""
); );
const [searchBox, setSearchBox] = useState(
router.pathname.startsWith("/search") || false
);
return ( return (
<div <div className="flex items-center relative group">
className="flex items-center relative group"
onClick={() => setSearchBox(true)}
>
<label <label
htmlFor="search-box" htmlFor="search-box"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md p-1 text-sky-500 dark:text-sky-500" className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md p-1 text-sky-500 dark:text-sky-500"
@ -43,7 +36,6 @@ export default function Search() {
e.key === "Enter" && e.key === "Enter" &&
router.push("/search?q=" + encodeURIComponent(searchQuery)) router.push("/search?q=" + encodeURIComponent(searchQuery))
} }
autoFocus={searchBox}
className="border border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-10 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" className="border border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-10 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"
/> />
</div> </div>

View File

@ -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" className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/> />
) : undefined} ) : undefined}
<div className="drop-shadow text-gray-500 dark:text-gray-300 text-xs">
{e._count?.links}
</div>
</div> </div>
</Link> </Link>
); );
@ -235,6 +238,9 @@ export default function Sidebar({ className }: { className?: string }) {
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="text-black dark:text-white truncate w-full pr-7">
{e.name} {e.name}
</p> </p>
<div className="drop-shadow text-gray-500 dark:text-gray-300 text-xs">
{e._count?.links}
</div>
</div> </div>
</Link> </Link>
); );

View File

@ -2,7 +2,11 @@ import { useTheme } from "next-themes";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; 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 { theme, setTheme } = useTheme();
const handleToggle = () => { const handleToggle = () => {
@ -15,15 +19,13 @@ export default function ToggleDarkMode() {
return ( return (
<div <div
className="flex gap-1 duration-100 h-10 rounded-full items-center w-fit cursor-pointer" className={`cursor-pointer flex select-none border border-sky-600 items-center justify-center dark:bg-neutral-900 bg-white hover:border-sky-500 group duration-100 rounded-full text-white w-10 h-10 ${className}`}
onClick={handleToggle} onClick={handleToggle}
> >
<div className="shadow bg-sky-700 dark:bg-sky-400 flex items-center justify-center rounded-full text-white w-10 h-10 duration-100">
<FontAwesomeIcon <FontAwesomeIcon
icon={theme === "dark" ? faSun : faMoon} icon={theme === "dark" ? faSun : faMoon}
className="w-1/2 h-1/2" className="w-1/2 h-1/2 text-sky-600 group-hover:text-sky-500"
/> />
</div> </div>
</div>
); );
} }

View File

@ -1,7 +1,7 @@
version: "3.5" version: "3.5"
services: services:
postgres: postgres:
image: postgres image: postgres:16-alpine
env_file: .env env_file: .env
restart: always restart: always
volumes: volumes:

View File

@ -50,13 +50,17 @@ export default function useLinks(
.join("&"); .join("&");
}; };
const queryString = buildQueryString(params); let queryString = buildQueryString(params);
const response = await fetch( let basePath;
`/api/v1/${
router.asPath === "/dashboard" ? "dashboard" : "links" if (router.pathname === "/dashboard") basePath = "/api/v1/dashboard";
}?${queryString}` 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(); const data = await response.json();

View File

@ -5,8 +5,7 @@ import useModalStore from "@/store/modals";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import ClickAwayHandler from "@/components/ClickAwayHandler"; import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions"; import useWindowDimensions from "@/hooks/useWindowDimensions";
import { import {
faPen, faPen,
@ -66,22 +65,22 @@ export default function LinkLayout({ children }: Props) {
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>(); const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
useEffect(() => { 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]); }, [links]);
useEffect(() => { useEffect(() => {
if (link) if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection.id)); setLinkCollection(collections.find((e) => e.id === link?.collection.id));
}, [link]); }, [link, collections]);
return ( return (
<> <>
<ModalManagement /> <ModalManagement />
<div className="flex mx-auto"> <div className="flex mx-auto">
<div className="hidden lg:block fixed left-5 h-screen"> {/* <div className="hidden lg:block fixed left-5 h-screen">
<LinkSidebar /> <LinkSidebar />
</div> </div> */}
<div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5"> <div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5">
<div className="flex gap-3 mb-5 duration-100 items-center justify-between"> <div className="flex gap-3 mb-5 duration-100 items-center justify-between">
@ -93,19 +92,31 @@ export default function LinkLayout({ children }: Props) {
</div> */} </div> */}
<div <div
onClick={() => 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" 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"
> >
<FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" /> <FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" />
Back{" "} Back{" "}
<span className="hidden sm:inline-block"> <span className="hidden sm:inline-block">
to <span className="capitalize">{linkCollection?.name}</span> to{" "}
<span className="capitalize">
{linkCollection?.name || link?.collection?.name}
</span>
</span> </span>
</div> </div>
<div className="lg:hidden">
<div className="flex gap-5"> <div className="flex gap-5">
{link?.collection.ownerId === userId || {link?.collection?.ownerId === userId ||
linkCollection?.members.some( linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate (e) => e.userId === userId && e.canUpdate
) ? ( ) ? (
@ -150,7 +161,7 @@ export default function LinkLayout({ children }: Props) {
/> />
</div> </div>
{link?.collection.ownerId === userId || {link?.collection?.ownerId === userId ||
linkCollection?.members.some( linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete (e) => e.userId === userId && e.canDelete
) ? ( ) ? (
@ -172,7 +183,6 @@ export default function LinkLayout({ children }: Props) {
) : undefined} ) : undefined}
</div> </div>
</div> </div>
</div>
{children} {children}

View File

@ -1,5 +1,5 @@
import { prisma } from "@/lib/api/db"; 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"; import getPermission from "@/lib/api/getPermission";
export default async function getLinkById(userId: number, linkId: number) { export default async function getLinkById(userId: number, linkId: number) {
@ -27,7 +27,7 @@ export default async function getLinkById(userId: number, linkId: number) {
status: 401, status: 401,
}; };
else { else {
const updatedLink = await prisma.link.findUnique({ const link = await prisma.link.findUnique({
where: { where: {
id: linkId, 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 };
} }
} }

View File

@ -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 };
}
}

View File

@ -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 };
}
}

View File

@ -0,0 +1,88 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getLink(
query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly">
) {
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 };
}

View File

@ -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 };
}

View File

@ -1,6 +1,6 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
export default async function getPublicUserById( export default async function getPublicUser(
targetId: number | string, targetId: number | string,
isId: boolean, isId: boolean,
requestingId?: number requestingId?: number

View File

@ -30,6 +30,11 @@ export default async function getTags(userId: number) {
}, },
], ],
}, },
include: {
_count: {
select: { links: true },
},
},
// orderBy: { // orderBy: {
// links: { // links: {
// _count: "desc", // _count: "desc",

View File

@ -1,25 +1,36 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
type Props = { type Props = {
userId: number; userId?: number;
collectionId?: number; collectionId?: number;
linkId?: number; linkId?: number;
isPublic?: boolean;
}; };
export default async function getPermission({ export default async function getPermission({
userId, userId,
collectionId, collectionId,
linkId, linkId,
isPublic,
}: Props) { }: Props) {
if (linkId) { if (linkId) {
const check = await prisma.collection.findFirst({ const check = await prisma.collection.findFirst({
where: { where: {
[isPublic ? "OR" : "AND"]: [
{
id: collectionId,
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
links: { links: {
some: { some: {
id: linkId, id: linkId,
}, },
}, },
}, },
{
isPublic: isPublic ? true : undefined,
},
],
},
include: { members: true }, include: { members: true },
}); });
@ -27,10 +38,15 @@ export default async function getPermission({
} else if (collectionId) { } else if (collectionId) {
const check = await prisma.collection.findFirst({ const check = await prisma.collection.findFirst({
where: { where: {
AND: { [isPublic ? "OR" : "AND"]: [
{
id: collectionId, id: collectionId,
OR: [{ ownerId: userId }, { members: { some: { userId } } }], OR: [{ ownerId: userId }, { members: { some: { userId } } }],
}, },
{
isPublic: isPublic ? true : undefined,
},
],
}, },
include: { members: true }, include: { members: true },
}); });

View File

@ -1,33 +1,17 @@
import { import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
PublicCollectionIncludingLinks,
PublicLinkRequestQuery,
} from "@/types/global";
import { Dispatch, SetStateAction } from "react"; import { Dispatch, SetStateAction } from "react";
const getPublicCollectionData = async ( const getPublicCollectionData = async (
collectionId: number, collectionId: number,
prevData: PublicCollectionIncludingLinks, setData: Dispatch<
setData: Dispatch<SetStateAction<PublicCollectionIncludingLinks | undefined>> SetStateAction<CollectionIncludingMembersAndLinkCount | undefined>
>
) => { ) => {
const requestBody: PublicLinkRequestQuery = { const res = await fetch("/api/v1/public/collections/" + collectionId);
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 data = await res.json(); const data = await res.json();
prevData setData(data.response);
? setData({
...data.response,
links: [...prevData.links, ...data.response.links],
})
: setData(data.response);
return data; return data;
}; };

View File

@ -22,7 +22,6 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.15", "@headlessui/react": "^1.7.15",
"@mozilla/readability": "^0.4.4", "@mozilla/readability": "^0.4.4",
"@next/font": "13.4.9",
"@prisma/client": "^4.16.2", "@prisma/client": "^4.16.2",
"@stripe/stripe-js": "^1.54.1", "@stripe/stripe-js": "^1.54.1",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
@ -40,6 +39,7 @@
"eslint-config-next": "13.4.9", "eslint-config-next": "13.4.9",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"lottie-web": "^5.12.2",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "13.4.12", "next": "13.4.12",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",

View File

@ -1,20 +1,20 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import readFile from "@/lib/api/storage/readFile"; 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) { export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (!req.query.params) if (!req.query.params)
return res.status(401).json({ response: "Invalid parameters." }); return res.status(401).json({ response: "Invalid parameters." });
const user = await verifyUser({ req, res }); const token = await getToken({ req });
if (!user) return; const userId = token?.id;
const collectionId = req.query.params[0]; const collectionId = req.query.params[0];
const linkId = req.query.params[1]; const linkId = req.query.params[1];
const collectionIsAccessible = await getPermission({ const collectionIsAccessible = await getPermission({
userId: user.id, userId,
collectionId: Number(collectionId), collectionId: Number(collectionId),
}); });

View File

@ -2,20 +2,20 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import readFile from "@/lib/api/storage/readFile"; import readFile from "@/lib/api/storage/readFile";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import { getToken } from "next-auth/jwt";
export default async function Index(req: NextApiRequest, res: NextApiResponse) { export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const queryId = Number(req.query.id); const queryId = Number(req.query.id);
const user = await verifyUser({ req, res });
if (!user) return;
if (!queryId) if (!queryId)
return res return res
.setHeader("Content-Type", "text/plain") .setHeader("Content-Type", "text/plain")
.status(401) .status(401)
.send("Invalid parameters."); .send("Invalid parameters.");
if (user.id !== queryId) { const token = await getToken({ req });
const userId = token?.id;
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({
where: { where: {
id: queryId, id: queryId,
@ -25,19 +25,39 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
}, },
}); });
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: userId,
},
include: {
subscriptions: true,
},
});
const whitelistedUsernames = targetUser?.whitelistedUsers.map( const whitelistedUsernames = targetUser?.whitelistedUsers.map(
(whitelistedUsername) => whitelistedUsername.username (whitelistedUsername) => whitelistedUsername.username
); );
if ( if (!user?.username) {
targetUser?.isPrivate &&
user.username &&
!whitelistedUsernames?.includes(user.username)
) {
return res return res
.setHeader("Content-Type", "text/plain") .setHeader("Content-Type", "text/plain")
.status(400) .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.");
} }
} }

View File

@ -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"; import type { NextApiRequest, NextApiResponse } from "next";
export default async function collections( export default async function collection(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse
) { ) {
if (!req?.query?.body) { if (!req?.query?.id) {
return res return res
.status(401) .status(401)
.json({ response: "Please choose a valid collection." }); .json({ response: "Please choose a valid collection." });
} }
if (req.method === "GET") { if (req.method === "GET") {
const collection = await getCollection(req?.query?.body as string); const collection = await getPublicCollection(Number(req?.query?.id));
return res return res
.status(collection.status) .status(collection.status)
.json({ response: collection.response }); .json({ response: collection.response });

View File

@ -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<LinkRequestQuery, "tagId"> = {
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 });
}
}

View File

@ -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 });
}
}

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next"; 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"; import { getToken } from "next-auth/jwt";
export default async function users(req: NextApiRequest, res: NextApiResponse) { 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))); const isId = lookupId.split("").every((e) => Number.isInteger(parseInt(e)));
if (req.method === "GET") { 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 }); return res.status(users.status).json({ response: users.response });
} }
} }

View File

@ -146,7 +146,7 @@ export default function Index() {
> >
<div <div
id="link-banner" id="link-banner"
className="link-banner p-3 mb-6 relative bg-opacity-10 border border-solid border-sky-100 dark:border-neutral-700 shadow-md" className="link-banner p-5 mb-4 relative bg-opacity-10 border border-solid border-sky-100 dark:border-neutral-700 shadow-md"
> >
<div id="link-banner-inner" className="link-banner-inner"></div> <div id="link-banner-inner" className="link-banner-inner"></div>

View File

@ -1,12 +1,26 @@
"use client"; "use client";
import LinkCard from "@/components/PublicPage/LinkCard"; import PublicLinkCard from "@/components/PublicPage/PublicLinkCard";
import useDetectPageBottom from "@/hooks/useDetectPageBottom";
import getPublicCollectionData from "@/lib/client/getPublicCollectionData"; import getPublicCollectionData from "@/lib/client/getPublicCollectionData";
import { PublicCollectionIncludingLinks } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount, Sort } from "@/types/global";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { motion, Variants } from "framer-motion"; import { motion, Variants } from "framer-motion";
import Head from "next/head"; 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 = { const cardVariants: Variants = {
offscreen: { offscreen: {
@ -23,71 +37,222 @@ const cardVariants: Variants = {
}; };
export default function PublicCollections() { 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 router = useRouter();
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const [data, setData] = useState<PublicCollectionIncludingLinks>(); 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>(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<CollectionIncludingMembersAndLinkCount>();
useEffect(() => { useEffect(() => {
if (router.query.id) { if (router.query.id) {
getPublicCollectionData( getPublicCollectionData(Number(router.query.id), setCollection);
Number(router.query.id),
data as PublicCollectionIncludingLinks,
setData
);
} }
// document
// .querySelector("body")
// ?.classList.add(
// "bg-gradient-to-br",
// "from-slate-50",
// "to-sky-50",
// "min-h-screen"
// );
}, []); }, []);
useEffect(() => { useEffect(() => {
if (reachedBottom && router.query.id) { const fetchOwner = async () => {
getPublicCollectionData( if (collection) {
Number(router.query.id), const owner = await getPublicUserData(collection.ownerId as number);
data as PublicCollectionIncludingLinks, setCollectionOwner(owner);
setData
);
} }
};
setReachedBottom(false); fetchOwner();
}, [reachedBottom]); }, [collection]);
return data ? ( return collection ? (
<div className="max-w-4xl mx-auto p-5 bg"> <div
{data ? ( className="h-screen"
style={{
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
theme === "dark" ? "#262626" : "#f3f4f6"
} 50%, ${theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
}}
>
<ModalManagement />
{collection ? (
<Head> <Head>
<title>{data.name} | Linkwarden</title> <title>{collection.name} | Linkwarden</title>
<meta <meta
property="og:title" property="og:title"
content={`${data.name} | Linkwarden`} content={`${collection.name} | Linkwarden`}
key="title" key="title"
/> />
</Head> </Head>
) : undefined} ) : undefined}
<div <div className="max-w-4xl mx-auto p-5 bg">
className={`border border-solid border-sky-100 text-center bg-gradient-to-tr from-sky-100 from-10% via-gray-100 via-20% rounded-3xl shadow-lg p-5`} <div className="flex items-center justify-between">
> <p className="text-4xl font-thin mb-2 capitalize mt-10">
<p className="text-5xl text-black mb-5 capitalize">{data.name}</p> {collection.name}
</p>
{data.description && ( <div className="flex gap-2 items-center mt-8 min-w-fit">
<> <ToggleDarkMode className="w-8 h-8 flex" />
<hr className="mt-5 max-w-[30rem] mx-auto border-1 border-slate-400" /> <Link href="https://linkwarden.app/" target="_blank">
<p className="mt-2 text-gray-500">{data.description}</p> <Image
</> src={`/icon.png`}
)} width={551}
height={551}
alt="Linkwarden"
title="Linkwarden"
className="h-8 w-fit mx-auto"
/>
</Link>
</div>
</div> </div>
<div className="flex flex-col gap-5 my-8"> <div>
{data?.links?.map((e, i) => { <div className={`min-w-[15rem]`}>
<div
onClick={() =>
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 ? (
<ProfilePhoto
src={
collectionOwner.image ? collectionOwner.image : undefined
}
className={`w-8 h-8 border-2`}
/>
) : undefined}
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={e.user.image ? e.user.image : undefined}
className={`w-8 h-8 border-2`}
/>
);
})
.slice(0, 3)}
{collection?.members.length &&
collection.members.length - 3 > 0 ? (
<div className="w-8 h-8 min-w-[2rem] text-white text-sm flex items-center justify-center rounded-full border-2 bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700">
+{collection?.members?.length - 3}
</div>
) : null}
<p className="ml-2 mt-1 text-gray-500 dark:text-gray-300">
By {collectionOwner.name} and {collection.members.length}{" "}
others.
</p>
</div>
</div>
</div>
<p className="mt-5">{collection.description}</p>
<hr className="mt-5 border-1 border-neutral-500" />
<div className="flex mb-5 mt-10 flex-col gap-5">
<div className="flex justify-between">
<PublicSearchBar
placeHolder={`Search ${collection._count?.links} Links`}
/>
<div className="flex gap-3 items-center">
<div className="relative">
<div
onClick={() => setFilterDropdown(!filterDropdown)}
id="filter-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-neutral-500 hover:bg-opacity-40 duration-100 p-1"
>
<FontAwesomeIcon
icon={faFilter}
id="filter-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{filterDropdown ? (
<FilterSearchDropdown
setFilterDropdown={setFilterDropdown}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
) : null}
</div>
<div className="relative">
<div
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-neutral-500 hover:bg-opacity-40 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div>
</div>
</div>
<div className="flex flex-col gap-5">
{links
?.filter((e) => e.collectionId === Number(router.query.id))
.map((e, i) => {
return ( return (
<motion.div <motion.div
key={i} key={i}
@ -96,17 +261,19 @@ export default function PublicCollections() {
viewport={{ once: true, amount: 0.8 }} viewport={{ once: true, amount: 0.8 }}
> >
<motion.div variants={cardVariants}> <motion.div variants={cardVariants}>
<LinkCard link={e} count={i} /> <PublicLinkCard link={e as any} count={i} />
</motion.div> </motion.div>
</motion.div> </motion.div>
); );
})} })}
</div> </div>
{/* <p className="text-center font-bold text-gray-500"> {/* <p className="text-center text-gray-500">
List created with <span className="text-black">Linkwarden.</span> List created with <span className="text-black">Linkwarden.</span>
</p> */} </p> */}
</div> </div>
</div>
</div>
) : ( ) : (
<></> <></>
); );

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

@ -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<LinkIncludingShortenedCollectionAndTags>();
const [linkContent, setLinkContent] = useState<LinkContent>();
const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const router = useRouter();
useEffect(() => {
const fetchLink = async () => {
if (router.query.id) {
await getLink(Number(router.query.id), 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 (
<LinkLayout>
<div
className={`flex flex-col max-w-screen-md h-full ${
theme === "dark" ? "banner-dark-mode" : "banner-light-mode"
}`}
>
<div
id="link-banner"
className="link-banner p-5 mb-4 relative bg-opacity-10 border border-solid border-sky-100 dark:border-neutral-700 shadow-md"
>
<div id="link-banner-inner" className="link-banner-inner"></div>
<div className={`relative flex flex-col gap-3 items-start`}>
<div className="flex gap-3 items-end">
{!imageError && link?.url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={42}
height={42}
alt=""
id={"favicon-" + link.id}
className="select-none mt-2 w-10 rounded-md shadow border-[3px] border-white dark:border-neutral-900 bg-white dark:bg-neutral-900 aspect-square"
draggable="false"
onLoad={(e) => {
try {
const color = colorThief.getPalette(
e.target as HTMLImageElement,
4
);
setColorPalette(color);
} catch (err) {
console.log(err);
}
}}
onError={(e) => {
setImageError(true);
}}
/>
)}
<div className="flex gap-2 text-sm text-gray-500 dark:text-gray-300">
<p className=" min-w-fit">
{link?.createdAt
? new Date(link?.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: undefined}
</p>
{link?.url ? (
<>
<p></p>
<Link
href={link?.url || ""}
title={link?.url}
target="_blank"
className="hover:opacity-60 duration-100 break-all"
>
{isValidUrl(link?.url || "")
? new URL(link?.url as string).host
: undefined}
</Link>
</>
) : undefined}
</div>
</div>
<div className="flex flex-col gap-2">
<p className="capitalize text-2xl sm:text-3xl font-thin">
{unescapeString(link?.name || link?.description || "")}
</p>
<div className="flex gap-1 items-center flex-wrap">
<Link
href={`/collections/${link?.collection?.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
>
<FontAwesomeIcon
icon={faFolder}
className="w-5 h-5 drop-shadow"
style={{ color: link?.collection?.color }}
/>
<p
title={link?.collection?.name}
className="text-black dark:text-white text-lg truncate max-w-[12rem]"
>
{link?.collection?.name}
</p>
</Link>
{link?.tags.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
className="px-2 py-1 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</p>
</Link>
))}
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-5 h-full">
{link?.readabilityPath?.startsWith("archives") ? (
<div
className="line-break px-3 reader-view"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(linkContent?.content || "") || "",
}}
></div>
) : (
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800">
{link?.readabilityPath === "pending" ? (
<p className="text-center">
Generating readable format, please wait...
</p>
) : (
<>
<p className="text-center text-2xl text-black dark:text-white">
There is no reader view for this webpage
</p>
<p className="text-center text-sm text-black dark:text-white">
{link?.collection?.ownerId === userId
? "You can update (refetch) the preserved formats by managing them below"
: "The collections owners can refetch the preserved formats"}
</p>
{link?.collection?.ownerId === userId ? (
<div
onClick={() =>
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "FORMATS",
})
: undefined
}
className="mt-4 flex gap-2 w-fit mx-auto relative items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-md dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100"
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-5 h-5 duration-100"
/>
<p>Manage preserved formats</p>
</div>
) : undefined}
</>
)}
</div>
)}
</div>
</div>
</LinkLayout>
);
}

View File

@ -11,10 +11,9 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import { Tag } from "@prisma/client";
import useTagStore from "@/store/tags"; import useTagStore from "@/store/tags";
import SortDropdown from "@/components/SortDropdown"; import SortDropdown from "@/components/SortDropdown";
import { Sort } from "@/types/global"; import { Sort, TagIncludingLinkCount } from "@/types/global";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import Dropdown from "@/components/Dropdown"; import Dropdown from "@/components/Dropdown";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
@ -33,7 +32,7 @@ export default function Index() {
const [renameTag, setRenameTag] = useState(false); const [renameTag, setRenameTag] = useState(false);
const [newTagName, setNewTagName] = useState<string>(); const [newTagName, setNewTagName] = useState<string>();
const [activeTag, setActiveTag] = useState<Tag>(); const [activeTag, setActiveTag] = useState<TagIncludingLinkCount>();
useLinks({ tagId: Number(router.query.id), sort: sortBy }); useLinks({ tagId: Number(router.query.id), sort: sortBy });

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

View File

@ -17,7 +17,7 @@ type LinkStore = {
addLink: ( addLink: (
body: LinkIncludingShortenedCollectionAndTags body: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>; ) => Promise<ResponseObject>;
getLink: (linkId: number) => Promise<ResponseObject>; getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
updateLink: ( updateLink: (
link: LinkIncludingShortenedCollectionAndTags link: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>; ) => Promise<ResponseObject>;
@ -66,8 +66,12 @@ const useLinkStore = create<LinkStore>()((set) => ({
return { ok: response.ok, data: data.response }; return { ok: response.ok, data: data.response };
}, },
getLink: async (linkId) => { getLink: async (linkId, publicRoute) => {
const response = await fetch(`/api/v1/links/${linkId}`); const path = publicRoute
? `/api/v1/public/links/${linkId}`
: `/api/v1/links/${linkId}`;
const response = await fetch(path);
const data = await response.json(); const data = await response.json();

View File

@ -39,6 +39,14 @@ type Modal =
active?: CollectionIncludingMembersAndLinkCount; active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number; defaultIndex?: number;
} }
| {
modal: "COLLECTION";
state: boolean;
method: "VIEW_TEAM";
isOwner?: boolean;
active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
| null; | null;
type ModalsStore = { type ModalsStore = {
@ -46,11 +54,11 @@ type ModalsStore = {
setModal: (modal: Modal) => void; setModal: (modal: Modal) => void;
}; };
const useLocalSettingsStore = create<ModalsStore>((set) => ({ const useModalStore = create<ModalsStore>((set) => ({
modal: null, modal: null,
setModal: (modal: Modal) => { setModal: (modal: Modal) => {
set({ modal }); set({ modal });
}, },
})); }));
export default useLocalSettingsStore; export default useModalStore;

View File

@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { Tag } from "@prisma/client"; import { TagIncludingLinkCount } from "@/types/global";
type ResponseObject = { type ResponseObject = {
ok: boolean; ok: boolean;
@ -7,9 +7,9 @@ type ResponseObject = {
}; };
type TagStore = { type TagStore = {
tags: Tag[]; tags: TagIncludingLinkCount[];
setTags: () => void; setTags: () => void;
updateTag: (tag: Tag) => Promise<ResponseObject>; updateTag: (tag: TagIncludingLinkCount) => Promise<ResponseObject>;
removeTag: (tagId: number) => Promise<ResponseObject>; removeTag: (tagId: number) => Promise<ResponseObject>;
}; };

View File

@ -190,7 +190,11 @@ body {
/* Reader view custom stylings */ /* Reader view custom stylings */
.reader-view { .reader-view {
line-height: 3rem; line-height: 2.8rem;
}
.reader-view p {
font-size: 1.15rem;
line-height: 2.5rem;
} }
.reader-view h1 { .reader-view h1 {
font-size: 2.2rem; font-size: 2.2rem;

View File

@ -37,6 +37,10 @@ export interface CollectionIncludingMembersAndLinkCount
members: Member[]; members: Member[];
} }
export interface TagIncludingLinkCount extends Tag {
_count?: { links: number };
}
export interface AccountSettings extends User { export interface AccountSettings extends User {
newPassword?: string; newPassword?: string;
whitelistedUsers: string[]; whitelistedUsers: string[];

View File

@ -919,11 +919,6 @@
dependencies: dependencies:
glob "7.1.7" 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": "@next/swc-darwin-arm64@13.4.12":
version "13.4.12" version "13.4.12"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.12.tgz#326c830b111de8a1a51ac0cbc3bcb157c4c4f92c" 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: dependencies:
js-tokens "^3.0.0 || ^4.0.0" 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: lru-cache@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"