Merge pull request #274 from linkwarden/dev

Linkwarden v2.0
This commit is contained in:
Daniel 2023-11-07 23:25:42 +03:30 committed by GitHub
commit 3aafc0960c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
115 changed files with 2912 additions and 1559 deletions

View File

@ -9,6 +9,7 @@ STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT= AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION= NEXT_PUBLIC_DISABLE_REGISTRATION=
IMPORT_SIZE_LIMIT= IMPORT_SIZE_LIMIT=
RE_ARCHIVE_LIMIT=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=

View File

@ -96,7 +96,6 @@ Here are the other ways to support/cheer this project:
- Starring this repository. - Starring this repository.
- Joining us on [Discord](https://discord.com/invite/CtuYV47nuJ). - Joining us on [Discord](https://discord.com/invite/CtuYV47nuJ).
- Following @daniel31x13 on [Mastodon](https://mastodon.social/@daniel31x13), [Twitter](https://twitter.com/daniel31x13) and [GitHub](https://github.com/daniel31x13).
- Referring Linkwarden to a friend. - Referring Linkwarden to a friend.
If you did any of the above, Thanksss! Otherwise thanks. If you did any of the above, Thanksss! Otherwise thanks.

View File

@ -0,0 +1,35 @@
import { faClose } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Link from "next/link";
import React, { MouseEventHandler } from "react";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
return (
<div className="fixed w-full z-20 dark:bg-neutral-900 bg-white">
<div className="w-full h-10 rainbow flex items-center justify-center">
<div className="w-fit font-semibold">
🎉{" "}
<Link
href="https://blog.linkwarden.app/releases/v2.0"
target="_blank"
className="underline hover:opacity-50 duration-100"
>
Linkwarden v2.0
</Link>{" "}
is now out! 🥳
</div>
<button
className="fixed top-3 right-3 hover:opacity-50 duration-100"
onClick={toggleAnnouncementBar}
>
<FontAwesomeIcon icon={faClose} className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@ -11,7 +11,9 @@ type Props = {
export default function Checkbox({ label, state, className, onClick }: Props) { export default function Checkbox({ label, state, className, onClick }: Props) {
return ( return (
<label className={`cursor-pointer flex items-center gap-2 ${className}`}> <label
className={`cursor-pointer flex items-center gap-2 ${className || ""}`}
>
<input <input
type="checkbox" type="checkbox"
checked={state} checked={state}

View File

@ -4,6 +4,8 @@ type Props = {
children: ReactNode; children: ReactNode;
onClickOutside: Function; onClickOutside: Function;
className?: string; className?: string;
style?: React.CSSProperties;
onMount?: (rect: DOMRect) => void;
}; };
function useOutsideAlerter( function useOutsideAlerter(
@ -30,12 +32,22 @@ export default function ClickAwayHandler({
children, children,
onClickOutside, onClickOutside,
className, className,
style,
onMount,
}: Props) { }: Props) {
const wrapperRef = useRef(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
useOutsideAlerter(wrapperRef, onClickOutside); useOutsideAlerter(wrapperRef, onClickOutside);
useEffect(() => {
if (wrapperRef.current && onMount) {
const rect = wrapperRef.current.getBoundingClientRect();
onMount(rect); // Pass the bounding rectangle to the parent
}
}, []);
return ( return (
<div ref={wrapperRef} className={className}> <div ref={wrapperRef} className={className} style={style}>
{children} {children}
</div> </div>
); );

View File

@ -15,6 +15,13 @@ type Props = {
className?: string; className?: string;
}; };
type DropdownTrigger =
| {
x: number;
y: number;
}
| false;
export default function CollectionCard({ collection, className }: Props) { export default function CollectionCard({ collection, className }: Props) {
const { setModal } = useModalStore(); const { setModal } = useModalStore();
@ -29,81 +36,86 @@ export default function CollectionCard({ collection, className }: Props) {
} }
); );
const [expandDropdown, setExpandDropdown] = useState(false); const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
const permissions = usePermissions(collection.id as number); const permissions = usePermissions(collection.id as number);
return ( return (
<div <>
style={{
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
theme === "dark" ? "#262626" : "#f3f4f6"
} 50%, ${theme === "dark" ? "#262626" : "#f9fafb"} 100%)`,
}}
className={`border border-solid border-sky-100 dark:border-neutral-700 self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none hover:opacity-80 group relative ${className}`}
>
<div <div
onClick={() => setExpandDropdown(!expandDropdown)} style={{
id={"expand-dropdown" + collection.id} backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
className="inline-flex absolute top-5 right-5 rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" theme === "dark" ? "#262626" : "#f3f4f6"
} 50%, ${theme === "dark" ? "#262626" : "#f9fafb"} 100%)`,
}}
className={`border border-solid border-sky-100 dark:border-neutral-700 self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none hover:opacity-80 group relative ${
className || ""
}`}
> >
<FontAwesomeIcon <div
icon={faEllipsis} onClick={(e) => setExpandDropdown({ x: e.clientX, y: e.clientY })}
id={"expand-dropdown" + collection.id} id={"expand-dropdown" + collection.id}
className="w-5 h-5 text-gray-500 dark:text-gray-300" className="inline-flex absolute top-5 right-5 rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
/> >
</div> <FontAwesomeIcon
<Link icon={faEllipsis}
href={`/collections/${collection.id}`} id={"expand-dropdown" + collection.id}
className="flex flex-col gap-2 justify-between min-h-[12rem] h-full select-none p-5" className="w-5 h-5 text-gray-500 dark:text-gray-300"
> />
<p className="text-2xl capitalize text-black dark:text-white break-words line-clamp-3 w-4/5">
{collection.name}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center w-full">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={`/api/v1/avatar/${e.userId}?${Date.now()}`}
className="-mr-3 border-[3px]"
/>
);
})
.slice(0, 4)}
{collection.members.length - 4 > 0 ? (
<div className="h-10 w-10 text-white flex items-center justify-center rounded-full border-[3px] bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700 -mr-3">
+{collection.members.length - 4}
</div>
) : null}
</div>
<div className="text-right w-40">
<div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? (
<FontAwesomeIcon
icon={faGlobe}
title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/>
) : undefined}
<FontAwesomeIcon
icon={faLink}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p className="font-bold text-xs">{formattedDate}</p>
</div>
</div>
</div> </div>
</Link> <Link
href={`/collections/${collection.id}`}
className="flex flex-col gap-2 justify-between min-h-[12rem] h-full select-none p-5"
>
<p className="text-2xl capitalize text-black dark:text-white break-words line-clamp-3 w-4/5">
{collection.name}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center w-full">
{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="-mr-3 border-[3px]"
/>
);
})
.slice(0, 4)}
{collection.members.length - 4 > 0 ? (
<div className="h-10 w-10 text-white flex items-center justify-center rounded-full border-[3px] bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700 -mr-3">
+{collection.members.length - 4}
</div>
) : null}
</div>
<div className="text-right w-40">
<div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? (
<FontAwesomeIcon
icon={faGlobe}
title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/>
) : undefined}
<FontAwesomeIcon
icon={faLink}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p className="font-bold text-xs">{formattedDate}</p>
</div>
</div>
</div>
</Link>
</div>
{expandDropdown ? ( {expandDropdown ? (
<Dropdown <Dropdown
points={{ x: expandDropdown.x, y: expandDropdown.y }}
items={[ items={[
permissions === true permissions === true
? { ? {
@ -159,9 +171,9 @@ export default function CollectionCard({ collection, className }: Props) {
if (target.id !== "expand-dropdown" + collection.id) if (target.id !== "expand-dropdown" + collection.id)
setExpandDropdown(false); setExpandDropdown(false);
}} }}
className="absolute top-[3.2rem] right-5 z-10 w-fit" className="w-fit"
/> />
) : null} ) : null}
</div> </>
); );
} }

View File

@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import React, { MouseEventHandler } from "react"; import React, { MouseEventHandler, useEffect, useState } from "react";
import ClickAwayHandler from "./ClickAwayHandler"; import ClickAwayHandler from "./ClickAwayHandler";
type MenuItem = type MenuItem =
@ -19,13 +19,66 @@ type Props = {
onClickOutside: Function; onClickOutside: Function;
className?: string; className?: string;
items: MenuItem[]; items: MenuItem[];
points?: { x: number; y: number };
style?: React.CSSProperties;
}; };
export default function Dropdown({ onClickOutside, className, items }: Props) { export default function Dropdown({
return ( points,
onClickOutside,
className,
items,
}: Props) {
const [pos, setPos] = useState<{ x: number; y: number }>();
const [dropdownHeight, setDropdownHeight] = useState<number>();
const [dropdownWidth, setDropdownWidth] = useState<number>();
function convertRemToPixels(rem: number) {
return (
rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
);
}
useEffect(() => {
if (points) {
let finalX = points.x;
let finalY = points.y;
// Check for x-axis overflow (left side)
if (dropdownWidth && points.x + dropdownWidth > window.innerWidth) {
finalX = points.x - dropdownWidth;
}
// Check for y-axis overflow (bottom side)
if (dropdownHeight && points.y + dropdownHeight > window.innerHeight) {
finalY =
window.innerHeight -
(dropdownHeight + (window.innerHeight - points.y));
}
setPos({ x: finalX, y: finalY });
}
}, [points, dropdownHeight]);
return !points || pos ? (
<ClickAwayHandler <ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside} onClickOutside={onClickOutside}
className={`${className} py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`} className={`${
className || ""
} py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`}
> >
{items.map((e, i) => { {items.map((e, i) => {
const inner = e && ( const inner = e && (
@ -49,5 +102,5 @@ export default function Dropdown({ onClickOutside, className, items }: Props) {
); );
})} })}
</ClickAwayHandler> </ClickAwayHandler>
); ) : null;
} }

View File

@ -1,12 +1,17 @@
import React, { SetStateAction } from "react"; import React, { SetStateAction } from "react";
import ClickAwayHandler from "./ClickAwayHandler"; import ClickAwayHandler from "./ClickAwayHandler";
import Checkbox from "./Checkbox"; import Checkbox from "./Checkbox";
import { LinkSearchFilter } from "@/types/global";
type Props = { type Props = {
setFilterDropdown: (value: SetStateAction<boolean>) => void; setFilterDropdown: (value: SetStateAction<boolean>) => void;
setSearchFilter: Function; setSearchFilter: Function;
searchFilter: LinkSearchFilter; searchFilter: {
name: boolean;
url: boolean;
description: boolean;
textContent: boolean;
tags: boolean;
};
}; };
export default function FilterSearchDropdown({ export default function FilterSearchDropdown({
@ -50,6 +55,16 @@ export default function FilterSearchDropdown({
}) })
} }
/> />
<Checkbox
label="Text Content"
state={searchFilter.textContent}
onClick={() =>
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
})
}
/>
<Checkbox <Checkbox
label="Tags" label="Tags"
state={searchFilter.tags} state={searchFilter.tags}

View File

@ -36,10 +36,6 @@ export const styles: StylesConfig = {
...styles, ...styles,
cursor: "pointer", cursor: "pointer",
}), }),
clearIndicator: (styles) => ({
...styles,
visibility: "hidden",
}),
placeholder: (styles) => ({ placeholder: (styles) => ({
...styles, ...styles,
borderColor: "black", borderColor: "black",

View File

@ -21,6 +21,7 @@ import { toast } from "react-hot-toast";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/client/isValidUrl";
import Link from "next/link"; import Link from "next/link";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { useRouter } from "next/router";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -28,12 +29,21 @@ type Props = {
className?: string; className?: string;
}; };
type DropdownTrigger =
| {
x: number;
y: number;
}
| false;
export default function LinkCard({ link, count, className }: Props) { export default function LinkCard({ link, count, className }: Props) {
const { setModal } = useModalStore(); const { setModal } = useModalStore();
const router = useRouter();
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
const [expandDropdown, setExpandDropdown] = useState(false); const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
@ -64,7 +74,7 @@ export default function LinkCard({ link, count, className }: Props) {
); );
}, [collections, links]); }, [collections, links]);
const { removeLink, updateLink } = useLinkStore(); const { removeLink, updateLink, getLink } = useLinkStore();
const pinLink = async () => { const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0]; const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
@ -84,6 +94,25 @@ export default function LinkCard({ link, count, className }: Props) {
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`); toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
}; };
const updateArchive = async () => {
const load = toast.loading("Sending request...");
setExpandDropdown(false);
const response = await fetch(`/api/v1/links/${link.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(`Link is being archived...`);
getLink(link.id as number);
} else toast.error(data.response);
};
const deleteLink = async () => { const deleteLink = async () => {
const load = toast.loading("Deleting..."); const load = toast.loading("Deleting...");
@ -107,100 +136,98 @@ export default function LinkCard({ link, count, className }: Props) {
); );
return ( return (
<div <>
className={`h-fit 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-2xl relative group ${className}`}
>
{(permissions === true ||
permissions?.canUpdate ||
permissions?.canDelete) && (
<div
onClick={() => setExpandDropdown(!expandDropdown)}
id={"expand-dropdown" + link.id}
className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-5 top-5 z-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
title="More"
className="w-5 h-5"
id={"expand-dropdown" + link.id}
/>
</div>
)}
<div <div
onClick={() => { className={`h-fit 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-2xl relative group ${
setModal({ className || ""
modal: "LINK", }`}
state: true,
method: "UPDATE",
isOwnerOrMod:
permissions === true || (permissions?.canUpdate as boolean),
active: link,
});
}}
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5"
> >
{url && ( {(permissions === true ||
<Image permissions?.canUpdate ||
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`} permissions?.canDelete) && (
width={64} <div
height={64} onClick={(e) => {
alt="" setExpandDropdown({ x: e.clientX, y: e.clientY });
className="blur-sm absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}} }}
/> id={"expand-dropdown" + link.id}
className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-5 top-5 z-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
title="More"
className="w-5 h-5"
id={"expand-dropdown" + link.id}
/>
</div>
)} )}
<div className="flex justify-between gap-5 w-full h-full z-0"> <div
<div className="flex flex-col justify-between w-full"> onClick={() => router.push("/links/" + link.id)}
<div className="flex items-baseline gap-1"> className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5"
<p className="text-sm text-gray-500 dark:text-gray-300"> >
{count + 1} {url && (
</p> <Image
<p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8"> src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
{unescapeString(link.name || link.description)} width={64}
</p> height={64}
</div> alt=""
<Link className="blur-sm absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none"
href={`/collections/${link.collection.id}`} draggable="false"
onClick={(e) => { onError={(e) => {
e.stopPropagation(); const target = e.target as HTMLElement;
target.style.display = "none";
}} }}
className="flex items-center gap-1 max-w-full w-fit my-3 hover:opacity-70 duration-100" />
> )}
<FontAwesomeIcon
icon={faFolder} <div className="flex justify-between gap-5 w-full h-full z-0">
className="w-4 h-4 mt-1 drop-shadow" <div className="flex flex-col justify-between w-full">
style={{ color: collection?.color }} <div className="flex items-baseline gap-1">
/> <p className="text-sm text-gray-500 dark:text-gray-300">
<p className="text-black dark:text-white truncate capitalize w-full"> {count + 1}
{collection?.name} </p>
</p> <p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8">
</Link> {unescapeString(link.name || link.description)}
<Link </p>
href={link.url} </div>
target="_blank" <Link
onClick={(e) => { href={`/collections/${link.collection.id}`}
e.stopPropagation(); onClick={(e) => {
}} e.stopPropagation();
className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100" }}
> className="flex items-center gap-1 max-w-full w-fit my-3 hover:opacity-70 duration-100"
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" /> >
<p className="truncate w-full">{shortendURL}</p> <FontAwesomeIcon
</Link> icon={faFolder}
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300"> className="w-4 h-4 mt-1 drop-shadow"
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" /> style={{ color: collection?.color }}
<p>{formattedDate}</p> />
<p className="text-black dark:text-white truncate capitalize w-full">
{collection?.name}
</p>
</Link>
<Link
href={link.url}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100"
>
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</Link>
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{expandDropdown ? ( {expandDropdown ? (
<Dropdown <Dropdown
points={{ x: expandDropdown.x, y: expandDropdown.y }}
items={[ items={[
permissions === true permissions === true
? { ? {
@ -219,15 +246,18 @@ export default function LinkCard({ link, count, className }: Props) {
modal: "LINK", modal: "LINK",
state: true, state: true,
method: "UPDATE", method: "UPDATE",
isOwnerOrMod:
permissions === true || permissions?.canUpdate,
active: link, active: link,
defaultIndex: 1,
}); });
setExpandDropdown(false); setExpandDropdown(false);
}, },
} }
: undefined, : undefined,
permissions === true
? {
name: "Refresh Formats",
onClick: updateArchive,
}
: undefined,
permissions === true || permissions?.canDelete permissions === true || permissions?.canDelete
? { ? {
name: "Delete", name: "Delete",
@ -240,9 +270,9 @@ export default function LinkCard({ link, count, className }: Props) {
if (target.id !== "expand-dropdown" + link.id) if (target.id !== "expand-dropdown" + link.id)
setExpandDropdown(false); setExpandDropdown(false);
}} }}
className="absolute top-12 right-5 w-40" className="w-40"
/> />
) : null} ) : null}
</div> </>
); );
} }

138
components/LinkSidebar.tsx Normal file
View File

@ -0,0 +1,138 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPen,
faBoxesStacked,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import useModalStore from "@/store/modals";
import useLinkStore from "@/store/links";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
type Props = {
className?: string;
onClick?: Function;
};
export default function SettingsSidebar({ className, onClick }: Props) {
const session = useSession();
const userId = session.data?.user.id;
const { setModal } = useModalStore();
const { links, removeLink } = useLinkStore();
const { collections } = useCollectionStore();
const [linkCollection, setLinkCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const router = useRouter();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection.id));
}, [link]);
return (
<div
className={`dark:bg-neutral-900 bg-white h-full lg:w-10 w-62 overflow-y-auto lg:p-0 p-5 border-solid border-white border dark:border-neutral-900 dark:lg:border-r-neutral-900 lg:border-r-white border-r-sky-100 dark:border-r-neutral-700 z-20 flex flex-col gap-5 lg:justify-center justify-start ${
className || ""
}`}
>
<div className="flex flex-col gap-5">
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate
) ? (
<div
title="Edit"
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "UPDATE",
})
: undefined;
onClick && onClick();
}}
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Edit
</p>
</div>
) : undefined}
<div
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "FORMATS",
})
: undefined;
onClick && onClick();
}}
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`}
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Preserved Formats
</p>
</div>
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete
) ? (
<div
onClick={() => {
if (link?.id) {
removeLink(link.id);
router.back();
onClick && onClick();
}
}}
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`}
>
<FontAwesomeIcon
icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Delete
</p>
</div>
) : undefined}
</div>
</div>
);
}

View File

@ -6,7 +6,6 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import RequiredBadge from "../../RequiredBadge";
import SubmitButton from "@/components/SubmitButton"; import SubmitButton from "@/components/SubmitButton";
import { HexColorPicker } from "react-colorful"; import { HexColorPicker } from "react-colorful";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -61,10 +60,7 @@ export default function CollectionInfo({
<div className="flex flex-col gap-3 sm:w-[35rem] w-80"> <div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="w-full"> <div className="w-full">
<p className="text-sm text-black dark:text-white mb-2"> <p className="text-black dark:text-white mb-2">Name</p>
Name
<RequiredBadge />
</p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<TextInput <TextInput
value={collection.name} value={collection.name}
@ -75,9 +71,7 @@ export default function CollectionInfo({
/> />
<div className="color-picker flex justify-between"> <div className="color-picker flex justify-between">
<div className="flex flex-col justify-between items-center w-32"> <div className="flex flex-col justify-between items-center w-32">
<p className="text-sm w-full text-black dark:text-white mb-2"> <p className="w-full text-black dark:text-white mb-2">Color</p>
Icon Color
</p>
<div style={{ color: collection.color }}> <div style={{ color: collection.color }}>
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
@ -102,7 +96,7 @@ export default function CollectionInfo({
</div> </div>
<div className="w-full"> <div className="w-full">
<p className="text-sm text-black dark:text-white mb-2">Description</p> <p className="text-black dark:text-white mb-2">Description</p>
<textarea <textarea
className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-gray-50 dark:bg-neutral-950 p-2 outline-none border-sky-100 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600" className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-gray-50 dark:bg-neutral-950 p-2 outline-none border-sky-100 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600"
placeholder="The purpose of this Collection..." placeholder="The purpose of this Collection..."

View File

@ -9,7 +9,6 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import { useSession } from "next-auth/react";
import addMemberToCollection from "@/lib/client/addMemberToCollection"; import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Checkbox from "../../Checkbox"; import Checkbox from "../../Checkbox";
import SubmitButton from "@/components/SubmitButton"; import SubmitButton from "@/components/SubmitButton";
@ -18,6 +17,7 @@ import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import useAccountStore from "@/store/account";
type Props = { type Props = {
toggleCollectionModal: Function; toggleCollectionModal: Function;
@ -34,26 +34,20 @@ export default function TeamManagement({
collection, collection,
method, method,
}: Props) { }: Props) {
const { account } = useAccountStore();
const permissions = usePermissions(collection.id as number); const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL); const currentURL = new URL(document.URL);
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`; const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
const [member, setMember] = useState<Member>({ const [memberUsername, setMemberUsername] = useState("");
canCreate: false,
canUpdate: false,
canDelete: false,
user: {
name: "",
username: "",
},
});
const [collectionOwner, setCollectionOwner] = useState({ const [collectionOwner, setCollectionOwner] = useState({
id: null, id: null,
name: "", name: "",
username: "", username: "",
image: "",
}); });
useEffect(() => { useEffect(() => {
@ -67,8 +61,6 @@ export default function TeamManagement({
const { addCollection, updateCollection } = useCollectionStore(); const { addCollection, updateCollection } = useCollectionStore();
const session = useSession();
const setMemberState = (newMember: Member) => { const setMemberState = (newMember: Member) => {
if (!collection) return null; if (!collection) return null;
@ -77,15 +69,7 @@ export default function TeamManagement({
members: [...collection.members, newMember], members: [...collection.members, newMember],
}); });
setMember({ setMemberUsername("");
canCreate: false,
canUpdate: false,
canDelete: false,
user: {
name: "",
username: "",
},
});
}; };
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
@ -118,7 +102,7 @@ export default function TeamManagement({
<div className="flex flex-col gap-3 sm:w-[35rem] w-80"> <div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{permissions === true && ( {permissions === true && (
<> <>
<p className="text-sm text-black dark:text-white">Make Public</p> <p className="text-black dark:text-white">Make Public</p>
<Checkbox <Checkbox
label="Make this a public collection." label="Make this a public collection."
@ -136,7 +120,7 @@ export default function TeamManagement({
{collection.isPublic ? ( {collection.isPublic ? (
<div> <div>
<p className="text-sm text-black dark:text-white mb-2"> <p className="text-black dark:text-white mb-2">
Public Link (Click to copy) Public Link (Click to copy)
</p> </p>
<div <div
@ -162,25 +146,18 @@ export default function TeamManagement({
{permissions === true && ( {permissions === true && (
<> <>
<p className="text-sm text-black dark:text-white"> <p className="text-black dark:text-white">Member Management</p>
Member Management
</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TextInput <TextInput
value={member.user.username || ""} value={memberUsername || ""}
placeholder="Username (without the '@')" placeholder="Username (without the '@')"
onChange={(e) => { onChange={(e) => setMemberUsername(e.target.value)}
setMember({
...member,
user: { ...member.user, username: e.target.value },
});
}}
onKeyDown={(e) => onKeyDown={(e) =>
e.key === "Enter" && e.key === "Enter" &&
addMemberToCollection( addMemberToCollection(
session.data?.user.username as string, account.username as string,
member.user.username || "", memberUsername || "",
collection, collection,
setMemberState setMemberState
) )
@ -190,8 +167,8 @@ export default function TeamManagement({
<div <div
onClick={() => onClick={() =>
addMemberToCollection( addMemberToCollection(
session.data?.user.username as string, account.username as string,
member.user.username || "", memberUsername || "",
collection, collection,
setMemberState setMemberState
) )
@ -238,7 +215,7 @@ export default function TeamManagement({
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProfilePhoto <ProfilePhoto
src={`/api/v1/avatar/${e.userId}?${Date.now()}`} src={e.user.image ? e.user.image : undefined}
className="border-[3px]" className="border-[3px]"
/> />
<div> <div>
@ -425,7 +402,7 @@ export default function TeamManagement({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProfilePhoto <ProfilePhoto
src={`/api/v1/avatar/${collection.ownerId}?${Date.now()}`} src={collectionOwner.image ? collectionOwner.image : undefined}
className="border-[3px]" className="border-[3px]"
/> />
<div> <div>

View File

@ -4,8 +4,7 @@ import TagSelection from "@/components/InputSelect/TagSelection";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faLink, faPlus } from "@fortawesome/free-solid-svg-icons";
import RequiredBadge from "../../RequiredBadge";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -14,6 +13,7 @@ import { toast } from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = type Props =
| { | {
@ -46,6 +46,10 @@ export default function AddOrEditLink({
url: "", url: "",
description: "", description: "",
tags: [], tags: [],
screenshotPath: "",
pdfPath: "",
readabilityPath: "",
textContent: "",
collection: { collection: {
name: "", name: "",
ownerId: data?.user.id as number, ownerId: data?.user.id as number,
@ -133,24 +137,21 @@ export default function AddOrEditLink({
return ( return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80"> <div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{method === "UPDATE" ? ( {method === "UPDATE" ? (
<p <div
className="text-gray-500 dark:text-gray-300 text-center truncate w-full" className="text-gray-500 dark:text-gray-300 break-all w-full flex gap-2"
title={link.url} title={link.url}
> >
Editing:{" "} <FontAwesomeIcon icon={faLink} className="w-6 h-6" />
<Link href={link.url} target="_blank"> <Link href={link.url} target="_blank" className="w-full">
{link.url} {link.url}
</Link> </Link>
</p> </div>
) : null} ) : null}
{method === "CREATE" ? ( {method === "CREATE" ? (
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3"> <div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5"> <div className="sm:col-span-3 col-span-5">
<p className="text-sm text-black dark:text-white mb-2 font-bold"> <p className="text-black dark:text-white mb-2">Address (URL)</p>
Address (URL)
<RequiredBadge />
</p>
<TextInput <TextInput
value={link.url} value={link.url}
onChange={(e) => setLink({ ...link, url: e.target.value })} onChange={(e) => setLink({ ...link, url: e.target.value })}
@ -158,9 +159,7 @@ export default function AddOrEditLink({
/> />
</div> </div>
<div className="sm:col-span-2 col-span-5"> <div className="sm:col-span-2 col-span-5">
<p className="text-sm text-black dark:text-white mb-2"> <p className="text-black dark:text-white mb-2">Collection</p>
Collection
</p>
{link.collection.name ? ( {link.collection.name ? (
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
@ -187,10 +186,10 @@ export default function AddOrEditLink({
{optionsExpanded ? ( {optionsExpanded ? (
<div> <div>
<hr className="mb-3 border border-sky-100 dark:border-neutral-700" /> {/* <hr className="mb-3 border border-sky-100 dark:border-neutral-700" /> */}
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
<div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}> <div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}>
<p className="text-sm text-black dark:text-white mb-2">Name</p> <p className="text-black dark:text-white mb-2">Name</p>
<TextInput <TextInput
value={link.name} value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })} onChange={(e) => setLink({ ...link, name: e.target.value })}
@ -200,9 +199,7 @@ export default function AddOrEditLink({
{method === "UPDATE" ? ( {method === "UPDATE" ? (
<div> <div>
<p className="text-sm text-black dark:text-white mb-2"> <p className="text-black dark:text-white mb-2">Collection</p>
Collection
</p>
{link.collection.name ? ( {link.collection.name ? (
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
@ -223,7 +220,7 @@ export default function AddOrEditLink({
) : undefined} ) : undefined}
<div> <div>
<p className="text-sm text-black dark:text-white mb-2">Tags</p> <p className="text-black dark:text-white mb-2">Tags</p>
<TagSelection <TagSelection
onChange={setTags} onChange={setTags}
defaultValue={link.tags.map((e) => { defaultValue={link.tags.map((e) => {
@ -233,9 +230,7 @@ export default function AddOrEditLink({
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<p className="text-sm text-black dark:text-white mb-2"> <p className="text-black dark:text-white mb-2">Description</p>
Description
</p>
<textarea <textarea
value={unescapeString(link.description) as string} value={unescapeString(link.description) as string}
onChange={(e) => onChange={(e) =>
@ -253,14 +248,14 @@ export default function AddOrEditLink({
</div> </div>
) : undefined} ) : undefined}
<div className="flex justify-between items-center mt-2"> <div className="flex justify-between items-stretch mt-2">
<div <div
onClick={() => setOptionsExpanded(!optionsExpanded)} onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`${ className={`${
method === "UPDATE" ? "hidden" : "" method === "UPDATE" ? "hidden" : ""
} rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 py-1 px-2 w-fit text-sm`} } rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 flex items-center px-2 w-fit text-sm`}
> >
{optionsExpanded ? "Hide" : "More"} Options <p>{optionsExpanded ? "Hide" : "More"} Options</p>
</div> </div>
<SubmitButton <SubmitButton

View File

@ -1,326 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpRightFromSquare,
faBoxArchive,
faCloudArrowDown,
faFolder,
faGlobe,
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import {
faCalendarDays,
faFileImage,
faFilePdf,
} from "@fortawesome/free-regular-svg-icons";
import isValidUrl from "@/lib/client/isValidUrl";
import { useTheme } from "next-themes";
import unescapeString from "@/lib/client/unescapeString";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
isOwnerOrMod: boolean;
};
export default function LinkDetails({ link, isOwnerOrMod }: Props) {
const { theme } = useTheme();
const [imageError, setImageError] = useState<boolean>(false);
const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
const { collections } = useCollectionStore();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections]);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const colorThief = new ColorThief();
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
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]
)}, ${rgbToHex(
colorPalette[1][0],
colorPalette[1][1],
colorPalette[1][2]
)})`;
}
if (colorPalette[2] && colorPalette[3]) {
bannerInner.style.background = `linear-gradient(to right, ${rgbToHex(
colorPalette[2][0],
colorPalette[2][1],
colorPalette[2][2]
)}, ${rgbToHex(
colorPalette[3][0],
colorPalette[3][1],
colorPalette[3][2]
)})`;
}
}
}, [colorPalette, theme]);
const handleDownload = (format: "png" | "pdf") => {
const path = `/api/v1/archives/${link.collection.id}/${link.id}.${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download = format === "pdf" ? "PDF" : "Screenshot";
link.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<div
className={`flex flex-col gap-3 sm:w-[35rem] w-80 ${
isOwnerOrMod ? "" : "mt-12"
} ${theme === "dark" ? "banner-dark-mode" : "banner-light-mode"}`}
>
{!imageError && (
<div id="link-banner" className="link-banner h-32 -mx-5 -mt-5 relative">
<div id="link-banner-inner" className="link-banner-inner"></div>
</div>
)}
<div
className={`relative flex gap-5 items-start ${!imageError && "-mt-24"}`}
>
{!imageError && 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=""
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 w-full flex-col min-h-[3rem] justify-center drop-shadow">
<p className="text-2xl text-black dark:text-white capitalize break-words hyphens-auto">
{unescapeString(link.name)}
</p>
<Link
href={link.url}
target="_blank"
className={`${
link.name ? "text-sm" : "text-xl"
} text-gray-500 dark:text-gray-300 break-all hover:underline cursor-pointer w-fit`}
>
{url ? url.host : link.url}
</Link>
</div>
</div>
<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: collection?.color }}
/>
<p
title={collection?.name}
className="text-black dark:text-white text-lg truncate max-w-[12rem]"
>
{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>
{link.description && (
<>
<div className="text-black dark:text-white max-h-[20rem] my-3 rounded-md overflow-y-auto hyphens-auto">
{unescapeString(link.description)}
</div>
</>
)}
<div className="flex justify-between items-center">
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faBoxArchive} className="w-4 h-4" />
<p>Archived Formats:</p>
</div>
<div
className="flex items-center gap-1 text-gray-500 dark:text-gray-300"
title={"Created at: " + formattedDate}
>
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">Screenshot</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-sky-500 dark:text-sky-500"
/>
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.png`}
target="_blank"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
</div>
</div>
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">PDF</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-sky-500 dark:text-sky-500"
/>
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.pdf`}
target="_blank"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
</div>
</div>
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faGlobe} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">
Latest archive.org Snapshot
</p>
</div>
<Link
href={`https://web.archive.org/web/${link.url.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,195 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpRightFromSquare,
faCloudArrowDown,
} from "@fortawesome/free-solid-svg-icons";
import { faFileImage, faFilePdf } from "@fortawesome/free-regular-svg-icons";
import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
export default function PreservedFormats() {
const session = useSession();
const { links, getLink } = useLinkStore();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const router = useRouter();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
let interval: NodeJS.Timer | undefined;
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") {
interval = setInterval(() => getLink(link.id as number), 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.screenshotPath, link?.pdfPath, link?.readabilityPath]);
const updateArchive = async () => {
const load = toast.loading("Sending request...");
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(`Link is being archived...`);
getLink(link?.id as number);
} else toast.error(data.response);
};
const handleDownload = (format: "png" | "pdf") => {
const path = `/api/v1/archives/${link?.collection.id}/${link?.id}.${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download = format === "pdf" ? "PDF" : "Screenshot";
link.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<div className={`flex flex-col gap-3 sm:w-[35rem] w-80 pt-3`}>
{link?.screenshotPath && link?.screenshotPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">Screenshot</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300"
/>
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.png`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</Link>
</div>
</div>
) : undefined}
{link?.pdfPath && link.pdfPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">PDF</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300"
/>
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.pdf`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</Link>
</div>
</div>
) : undefined}
<div className="flex flex-col-reverse sm:flex-row gap-5 items-center justify-center">
{link?.collection.ownerId === session.data?.user.id ? (
<div
className={`w-full text-center bg-sky-700 p-1 rounded-md cursor-pointer select-none hover:bg-sky-600 duration-100 ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "mt-3"
: ""
}`}
onClick={() => updateArchive()}
>
<p>Update Preserved Formats</p>
<p className="text-xs">(Refresh Formats)</p>
</div>
) : undefined}
<Link
href={`https://web.archive.org/web/${link?.url.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className={`text-gray-500 dark:text-gray-300 duration-100 hover:opacity-60 flex gap-2 w-fit items-center text-sm ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "sm:mt-3"
: ""
}`}
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-4 h-4"
/>
<p className="whitespace-nowrap">
View Latest Snapshot on archive.org
</p>
</Link>
</div>
</div>
);
}

View File

@ -1,89 +1,63 @@
import { Tab } from "@headlessui/react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import AddOrEditLink from "./AddOrEditLink"; import AddOrEditLink from "./AddOrEditLink";
import LinkDetails from "./LinkDetails"; import PreservedFormats from "./PreservedFormats";
type Props = type Props =
| { | {
toggleLinkModal: Function; toggleLinkModal: Function;
method: "CREATE"; method: "CREATE";
isOwnerOrMod?: boolean;
activeLink?: LinkIncludingShortenedCollectionAndTags; activeLink?: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number;
className?: string; className?: string;
} }
| { | {
toggleLinkModal: Function; toggleLinkModal: Function;
method: "UPDATE"; method: "UPDATE";
isOwnerOrMod: boolean;
activeLink: LinkIncludingShortenedCollectionAndTags; activeLink: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number; className?: string;
}
| {
toggleLinkModal: Function;
method: "FORMATS";
activeLink: LinkIncludingShortenedCollectionAndTags;
className?: string; className?: string;
}; };
export default function LinkModal({ export default function LinkModal({
className, className,
defaultIndex,
toggleLinkModal, toggleLinkModal,
isOwnerOrMod,
activeLink, activeLink,
method, method,
}: Props) { }: Props) {
return ( return (
<div className={className}> <div className={className}>
<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 Link Create a New Link
</p> </p>
)} <AddOrEditLink toggleLinkModal={toggleLinkModal} method="CREATE" />
<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" && isOwnerOrMod && ( ) : undefined}
<>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 rounded-md duration-100 outline-none"
}
>
Link Details
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 rounded-md duration-100 outline-none"
}
>
Edit Link
</Tab>
</>
)}
</Tab.List>
<Tab.Panels>
{activeLink && method === "UPDATE" && (
<Tab.Panel>
<LinkDetails link={activeLink} isOwnerOrMod={isOwnerOrMod} />
</Tab.Panel>
)}
<Tab.Panel> {activeLink && method === "UPDATE" ? (
{activeLink && method === "UPDATE" ? ( <>
<AddOrEditLink <p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">Edit Link</p>
toggleLinkModal={toggleLinkModal} <AddOrEditLink
method="UPDATE" toggleLinkModal={toggleLinkModal}
activeLink={activeLink} method="UPDATE"
/> activeLink={activeLink}
) : ( />
<AddOrEditLink </>
toggleLinkModal={toggleLinkModal} ) : undefined}
method="CREATE"
/> {method === "FORMATS" ? (
)} <>
</Tab.Panel> <p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
</Tab.Panels> Preserved Formats
</Tab.Group> </p>
<PreservedFormats />
</>
) : undefined}
</div> </div>
); );
} }

View File

@ -14,7 +14,7 @@ export default function Modal({ toggleModal, className, children }: Props) {
<div className="overflow-y-auto py-2 fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-30"> <div className="overflow-y-auto py-2 fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-30">
<ClickAwayHandler <ClickAwayHandler
onClickOutside={toggleModal} onClickOutside={toggleModal}
className={`m-auto ${className}`} className={`m-auto ${className || ""}`}
> >
<div className="slide-up relative border-sky-100 dark:border-neutral-700 rounded-2xl border-solid border shadow-lg p-5 bg-white dark:bg-neutral-900"> <div className="slide-up relative border-sky-100 dark:border-neutral-700 rounded-2xl border-solid border shadow-lg p-5 bg-white dark:bg-neutral-900">
<div <div

View File

@ -27,8 +27,6 @@ export default function ModalManagement() {
<LinkModal <LinkModal
toggleLinkModal={toggleModal} toggleLinkModal={toggleModal}
method={modal.method} method={modal.method}
isOwnerOrMod={modal.isOwnerOrMod as boolean}
defaultIndex={modal.defaultIndex}
activeLink={modal.active as LinkIncludingShortenedCollectionAndTags} activeLink={modal.active as LinkIncludingShortenedCollectionAndTags}
/> />
</Modal> </Modal>

View File

@ -83,7 +83,7 @@ export default function Navbar() {
id="profile-dropdown" id="profile-dropdown"
> >
<ProfilePhoto <ProfilePhoto
src={account.profilePic} src={account.image ? account.image : undefined}
priority={true} priority={true}
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]" className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]"
/> />

View File

@ -2,51 +2,45 @@ import React, { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/free-solid-svg-icons"; import { faUser } from "@fortawesome/free-solid-svg-icons";
import Image from "next/image"; import Image from "next/image";
import avatarExists from "@/lib/client/avatarExists";
type Props = { type Props = {
src: string; src?: string;
className?: string; className?: string;
emptyImage?: boolean; emptyImage?: boolean;
status?: Function;
priority?: boolean; priority?: boolean;
}; };
export default function ProfilePhoto({ export default function ProfilePhoto({ src, className, priority }: Props) {
src, const [image, setImage] = useState("");
className,
emptyImage,
status,
priority,
}: Props) {
const [error, setError] = useState<boolean>(emptyImage || true);
const checkAvatarExistence = async () => {
const canPass = await avatarExists(src);
setError(!canPass);
};
useEffect(() => { useEffect(() => {
if (src) checkAvatarExistence(); if (src && !src?.includes("base64"))
setImage(`/api/v1/${src.replace("uploads/", "").replace(".jpg", "")}`);
else if (!src) setImage("");
else {
setImage(src);
}
}, [src]);
status && status(error || !src); return !image ? (
}, [src, error]);
return error || !src ? (
<div <div
className={`bg-sky-600 dark:bg-sky-600 text-white h-10 w-10 aspect-square shadow rounded-full border border-slate-200 dark:border-neutral-700 flex items-center justify-center ${className}`} className={`bg-sky-600 dark:bg-sky-600 text-white h-10 w-10 aspect-square shadow rounded-full border border-slate-200 dark:border-neutral-700 flex items-center justify-center ${
className || ""
}`}
> >
<FontAwesomeIcon icon={faUser} className="w-1/2 h-1/2 aspect-square" /> <FontAwesomeIcon icon={faUser} className="w-1/2 h-1/2 aspect-square" />
</div> </div>
) : ( ) : (
<Image <Image
alt="" alt=""
src={src} src={image}
height={112} height={112}
width={112} width={112}
priority={priority} priority={priority}
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}`} draggable={false}
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 || ""
}`}
/> />
); );
} }

View File

@ -32,7 +32,9 @@ export default function SettingsSidebar({ className }: { className?: string }) {
return ( return (
<div <div
className={`dark:bg-neutral-900 bg-white h-full w-64 overflow-y-auto border-solid border-white border dark:border-neutral-900 border-r-sky-100 dark:border-r-neutral-700 p-5 z-20 flex flex-col gap-5 justify-between ${className}`} className={`dark:bg-neutral-900 bg-white h-full w-64 overflow-y-auto border-solid border-white border dark:border-neutral-900 border-r-sky-100 dark:border-r-neutral-700 p-5 z-20 flex flex-col gap-5 justify-between ${
className || ""
}`}
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Link href="/settings/account"> <Link href="/settings/account">
@ -111,7 +113,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div> </div>
</Link> </Link>
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? ( {process.env.NEXT_PUBLIC_STRIPE ? (
<Link href="/settings/billing"> <Link href="/settings/billing">
<div <div
className={`${ className={`${

View File

@ -51,7 +51,9 @@ export default function Sidebar({ className }: { className?: string }) {
return ( return (
<div <div
className={`bg-gray-100 dark:bg-neutral-800 h-full w-64 xl:w-80 overflow-y-auto border-solid border dark:border-neutral-800 border-r-sky-100 dark:border-r-neutral-700 px-2 z-20 ${className}`} className={`bg-gray-100 dark:bg-neutral-800 h-full w-64 xl:w-80 overflow-y-auto border-solid border dark:border-neutral-800 border-r-sky-100 dark:border-r-neutral-700 px-2 z-20 ${
className || ""
}`}
> >
<div className="flex justify-center gap-2 mt-2"> <div className="flex justify-center gap-2 mt-2">
<Link <Link

View File

@ -21,7 +21,7 @@ export default function SortDropdown({
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (target.id !== "sort-dropdown") toggleSortDropdown(); if (target.id !== "sort-dropdown") toggleSortDropdown();
}} }}
className="absolute top-8 right-0 border border-sky-100 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-48" className="absolute top-8 right-0 border border-sky-100 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-52"
> >
<p className="mb-2 text-black dark:text-white text-center font-semibold"> <p className="mb-2 text-black dark:text-white text-center font-semibold">
Sort by Sort by

View File

@ -25,7 +25,7 @@ export default function SubmitButton({
loading loading
? "bg-sky-600 cursor-auto" ? "bg-sky-600 cursor-auto"
: "bg-sky-700 hover:bg-sky-600 cursor-pointer" : "bg-sky-700 hover:bg-sky-600 cursor-pointer"
} ${className}`} } ${className || ""}`}
onClick={() => { onClick={() => {
if (!loading && onClick) onClick(); if (!loading && onClick) onClick();
}} }}

View File

@ -27,7 +27,9 @@ export default function TextInput({
value={value} value={value}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
className={`w-full rounded-md p-2 border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-950 border-solid border outline-none focus:border-sky-300 focus:dark:border-sky-600 duration-100 ${className}`} className={`w-full rounded-md p-2 border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-950 border-solid border outline-none focus:border-sky-300 focus:dark:border-sky-600 duration-100 ${
className || ""
}`}
/> />
); );
} }

View File

@ -9,17 +9,21 @@ export default function useInitialData() {
const { setCollections } = useCollectionStore(); const { setCollections } = useCollectionStore();
const { setTags } = useTagStore(); const { setTags } = useTagStore();
// const { setLinks } = useLinkStore(); // const { setLinks } = useLinkStore();
const { setAccount } = useAccountStore(); const { account, setAccount } = useAccountStore();
// Get account info
useEffect(() => { useEffect(() => {
if ( if (status === "authenticated") {
status === "authenticated" && setAccount(data?.user.id as number);
(!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE || data.user.isSubscriber) }
) { }, [status, data]);
// Get the rest of the data
useEffect(() => {
if (account.id && (!process.env.NEXT_PUBLIC_STRIPE || account.username)) {
setCollections(); setCollections();
setTags(); setTags();
// setLinks(); // setLinks();
setAccount(data.user.id);
} }
}, [status]); }, [account]);
} }

View File

@ -15,6 +15,7 @@ export default function useLinks(
searchByUrl, searchByUrl,
searchByDescription, searchByDescription,
searchByTags, searchByTags,
searchByTextContent,
}: LinkRequestQuery = { sort: 0 } }: LinkRequestQuery = { sort: 0 }
) { ) {
const { links, setLinks, resetLinks } = useLinkStore(); const { links, setLinks, resetLinks } = useLinkStore();
@ -34,6 +35,7 @@ export default function useLinks(
searchByUrl, searchByUrl,
searchByDescription, searchByDescription,
searchByTags, searchByTags,
searchByTextContent,
}; };
const buildQueryString = (params: LinkRequestQuery) => { const buildQueryString = (params: LinkRequestQuery) => {
@ -72,6 +74,7 @@ export default function useLinks(
searchByName, searchByName,
searchByUrl, searchByUrl,
searchByDescription, searchByDescription,
searchByTextContent,
searchByTags, searchByTags,
]); ]);

View File

@ -1,30 +0,0 @@
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
export default function useRedirect() {
const router = useRouter();
const { status } = useSession();
const [redirect, setRedirect] = useState(true);
useEffect(() => {
if (
status === "authenticated" &&
(router.pathname === "/login" || router.pathname === "/register")
) {
router.push("/").then(() => {
setRedirect(false);
});
} else if (
status === "unauthenticated" &&
!(router.pathname === "/login" || router.pathname === "/register")
) {
router.push("/login").then(() => {
setRedirect(false);
});
} else if (status === "loading") setRedirect(true);
else setRedirect(false);
}, [status]);
return redirect;
}

View File

@ -4,6 +4,7 @@ import Loader from "../components/Loader";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useInitialData from "@/hooks/useInitialData"; import useInitialData from "@/hooks/useInitialData";
import useAccountStore from "@/store/account";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@ -13,40 +14,49 @@ export default function AuthRedirect({ children }: Props) {
const router = useRouter(); const router = useRouter();
const { status, data } = useSession(); const { status, data } = useSession();
const [redirect, setRedirect] = useState(true); const [redirect, setRedirect] = useState(true);
const { account } = useAccountStore();
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
useInitialData(); useInitialData();
useEffect(() => { useEffect(() => {
if (!router.pathname.startsWith("/public")) { if (!router.pathname.startsWith("/public")) {
if ( if (
status === "authenticated" &&
account.id &&
!account.subscription?.active &&
stripeEnabled
) {
router.push("/subscribe").then(() => {
setRedirect(false);
});
}
// Redirect to "/choose-username" if user is authenticated and is either a subscriber OR subscription is undefiend, and doesn't have a username
else if (
emailEnabled && emailEnabled &&
status === "authenticated" && status === "authenticated" &&
(data.user.isSubscriber === true || account.subscription?.active &&
data.user.isSubscriber === undefined) && stripeEnabled &&
!data.user.username account.id &&
!account.username
) { ) {
router.push("/choose-username").then(() => { router.push("/choose-username").then(() => {
setRedirect(false); setRedirect(false);
}); });
} else if ( } else if (
status === "authenticated" && status === "authenticated" &&
data.user.isSubscriber === false account.id &&
) {
router.push("/subscribe").then(() => {
setRedirect(false);
});
} else if (
status === "authenticated" &&
(router.pathname === "/login" || (router.pathname === "/login" ||
router.pathname === "/register" || router.pathname === "/register" ||
router.pathname === "/confirmation" || router.pathname === "/confirmation" ||
router.pathname === "/subscribe" || router.pathname === "/subscribe" ||
router.pathname === "/choose-username" || router.pathname === "/choose-username" ||
router.pathname === "/forgot") router.pathname === "/forgot" ||
router.pathname === "/")
) { ) {
router.push("/").then(() => { router.push("/dashboard").then(() => {
setRedirect(false); setRedirect(false);
}); });
} else if ( } else if (
@ -66,7 +76,7 @@ export default function AuthRedirect({ children }: Props) {
} else { } else {
setRedirect(false); setRedirect(false);
} }
}, [status]); }, [status, account, router.pathname]);
if (status !== "loading" && !redirect) return <>{children}</>; if (status !== "loading" && !redirect) return <>{children}</>;
else return <></>; else return <></>;

View File

@ -10,10 +10,20 @@ interface Props {
export default function CenteredForm({ text, children }: Props) { export default function CenteredForm({ text, children }: Props) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5"> <div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5">
<div className="m-auto flex flex-col gap-2 w-full"> <div className="m-auto flex flex-col gap-2 w-full">
{theme === "dark" ? ( {theme ? (
<Image
src={`/linkwarden_${theme === "dark" ? "dark" : "li"}.png`}
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
/>
) : undefined}
{/* {theme === "dark" ? (
<Image <Image
src="/linkwarden_dark.png" src="/linkwarden_dark.png"
width={640} width={640}
@ -29,7 +39,7 @@ export default function CenteredForm({ text, children }: Props) {
alt="Linkwarden" alt="Linkwarden"
className="h-12 w-fit mx-auto" className="h-12 w-fit mx-auto"
/> />
)} )} */}
{text ? ( {text ? (
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold text-black dark:text-white px-2 text-center"> <p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold text-black dark:text-white px-2 text-center">
{text} {text}

195
layouts/LinkLayout.tsx Normal file
View File

@ -0,0 +1,195 @@
import LinkSidebar from "@/components/LinkSidebar";
import { ReactNode, useEffect, useState } from "react";
import ModalManagement from "@/components/ModalManagement";
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 useWindowDimensions from "@/hooks/useWindowDimensions";
import {
faPen,
faBoxesStacked,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import useLinkStore from "@/store/links";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
interface Props {
children: ReactNode;
}
export default function LinkLayout({ children }: Props) {
const { modal } = useModalStore();
const router = useRouter();
useEffect(() => {
modal
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "auto");
}, [modal]);
const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);
}, [router]);
const toggleSidebar = () => {
setSidebar(!sidebar);
};
const session = useSession();
const userId = session.data?.user.id;
const { setModal } = useModalStore();
const { links, removeLink } = useLinkStore();
const { collections } = useCollectionStore();
const [linkCollection, setLinkCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection.id));
}, [link]);
return (
<>
<ModalManagement />
<div className="flex mx-auto">
<div className="hidden lg:block fixed left-5 h-screen">
<LinkSidebar />
</div>
<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
onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div> */}
<div
onClick={() => 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"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" />
Back{" "}
<span className="hidden sm:inline-block">
to <span className="capitalize">{linkCollection?.name}</span>
</span>
</div>
<div className="lg:hidden">
<div className="flex gap-5">
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate
) ? (
<div
title="Edit"
onClick={() => {
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`}
>
<FontAwesomeIcon
icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
) : undefined}
<div
onClick={() => {
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`}
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete
) ? (
<div
onClick={() => {
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`}
>
<FontAwesomeIcon
icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
) : undefined}
</div>
</div>
</div>
{children}
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler
className="h-full"
onClickOutside={toggleSidebar}
>
<div className="slide-right h-full shadow-lg">
<LinkSidebar onClick={() => setSidebar(false)} />
</div>
</ClickAwayHandler>
</div>
) : null}
</div>
</div>
</>
);
}

View File

@ -1,8 +1,10 @@
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import AnnouncementBar from "@/components/AnnouncementBar";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { ReactNode, useEffect } from "react"; import { ReactNode, useEffect, useState } from "react";
import ModalManagement from "@/components/ModalManagement"; import ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals"; import useModalStore from "@/store/modals";
import getLatestVersion from "@/lib/client/getLatestVersion";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@ -17,16 +19,49 @@ export default function MainLayout({ children }: Props) {
: (document.body.style.overflow = "auto"); : (document.body.style.overflow = "auto");
}, [modal]); }, [modal]);
const showAnnouncementBar = localStorage.getItem("showAnnouncementBar");
const [showAnnouncement, setShowAnnouncement] = useState(
showAnnouncementBar ? showAnnouncementBar === "true" : true
);
useEffect(() => {
getLatestVersion(setShowAnnouncement);
}, []);
useEffect(() => {
if (showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "true");
setShowAnnouncement(true);
} else if (!showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "false");
setShowAnnouncement(false);
}
}, [showAnnouncement]);
const toggleAnnouncementBar = () => {
setShowAnnouncement(!showAnnouncement);
};
return ( return (
<> <>
<ModalManagement /> <ModalManagement />
{showAnnouncement ? (
<AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} />
) : undefined}
<div className="flex"> <div className="flex">
<div className="hidden lg:block"> <div className="hidden lg:block">
<Sidebar className="fixed top-0" /> <Sidebar
className={`fixed ${showAnnouncement ? "top-10" : "top-0"}`}
/>
</div> </div>
<div className="w-full flex flex-col min-h-screen lg:ml-64 xl:ml-80"> <div
className={`w-full flex flex-col min-h-screen lg:ml-64 xl:ml-80 ${
showAnnouncement ? "mt-10" : ""
}`}
>
<Navbar /> <Navbar />
{children} {children}
</div> </div>

View File

@ -2,18 +2,29 @@ import { chromium, devices } from "playwright";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import sendToWayback from "./sendToWayback"; import sendToWayback from "./sendToWayback";
import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";
export default async function archive( export default async function archive(
linkId: number, linkId: number,
url: string, url: string,
userId: number userId: number
) { ) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({ where: { id: userId } });
where: {
id: userId, const targetLink = await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: user?.archiveAsScreenshot ? "pending" : null,
pdfPath: user?.archiveAsPDF ? "pending" : null,
readabilityPath: "pending",
lastPreserved: new Date().toISOString(),
}, },
}); });
// Archive.org
if (user?.archiveAsWaybackMachine) sendToWayback(url); if (user?.archiveAsWaybackMachine) sendToWayback(url);
if (user?.archiveAsPDF || user?.archiveAsScreenshot) { if (user?.archiveAsPDF || user?.archiveAsScreenshot) {
@ -24,24 +35,48 @@ export default async function archive(
try { try {
await page.goto(url, { waitUntil: "domcontentloaded" }); await page.goto(url, { waitUntil: "domcontentloaded" });
await page.evaluate( const content = await page.content();
autoScroll,
Number(process.env.AUTOSCROLL_TIMEOUT) || 30
);
const linkExists = await prisma.link.findUnique({ // Readability
where: {
id: linkId, const window = new JSDOM("").window;
const purify = DOMPurify(window);
const cleanedUpContent = purify.sanitize(content);
const dom = new JSDOM(cleanedUpContent, { url: url });
const article = new Readability(dom.window.document).parse();
const articleText = article?.textContent
.replace(/ +(?= )/g, "") // strip out multiple spaces
.replace(/(\r\n|\n|\r)/gm, " "); // strip out line breaks
await createFile({
data: JSON.stringify(article),
filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
});
await prisma.link.update({
where: { id: linkId },
data: {
readabilityPath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
textContent: articleText,
}, },
}); });
if (linkExists) { // Screenshot/PDF
if (user.archiveAsScreenshot) {
const screenshot = await page.screenshot({
fullPage: true,
});
createFile({ let faulty = false;
await page
.evaluate(autoScroll, Number(process.env.AUTOSCROLL_TIMEOUT) || 30)
.catch((e) => (faulty = true));
const linkExists = await prisma.link.findUnique({
where: { id: linkId },
});
if (linkExists && !faulty) {
if (user.archiveAsScreenshot) {
const screenshot = await page.screenshot({ fullPage: true });
await createFile({
data: screenshot, data: screenshot,
filePath: `archives/${linkExists.collectionId}/${linkId}.png`, filePath: `archives/${linkExists.collectionId}/${linkId}.png`,
}); });
@ -55,16 +90,36 @@ export default async function archive(
margin: { top: "15px", bottom: "15px" }, margin: { top: "15px", bottom: "15px" },
}); });
createFile({ await createFile({
data: pdf, data: pdf,
filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`, filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`,
}); });
} }
}
await browser.close(); await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: user.archiveAsScreenshot
? `archives/${linkExists.collectionId}/${linkId}.png`
: null,
pdfPath: user.archiveAsPDF
? `archives/${linkExists.collectionId}/${linkId}.pdf`
: null,
},
});
} else if (faulty) {
await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: null,
pdfPath: null,
},
});
}
} catch (err) { } catch (err) {
console.log(err); console.log(err);
throw err;
} finally {
await browser.close(); await browser.close();
} }
} }
@ -73,11 +128,7 @@ export default async function archive(
const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => { const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => {
const timeoutPromise = new Promise<void>((_, reject) => { const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => { setTimeout(() => {
reject( reject(new Error(`Webpage was too long to be archived.`));
new Error(
`Auto scroll took too long (more than ${AUTOSCROLL_TIMEOUT} seconds).`
)
);
}, AUTOSCROLL_TIMEOUT * 1000); }, AUTOSCROLL_TIMEOUT * 1000);
}); });

View File

@ -1,49 +0,0 @@
import Stripe from "stripe";
export default async function checkSubscription(
stripeSecretKey: string,
email: string
) {
const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2022-11-15",
});
const listByEmail = await stripe.customers.list({
email: email.toLowerCase(),
expand: ["data.subscriptions"],
});
let subscriptionCanceledAt: number | null | undefined;
const isSubscriber = listByEmail.data.some((customer, i) => {
const hasValidSubscription = customer.subscriptions?.data.some(
(subscription) => {
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
const secondsInTwoWeeks = NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS) * 86400
: 1209600;
subscriptionCanceledAt = subscription.canceled_at;
const isNotCanceledOrHasTime = !(
subscription.canceled_at &&
new Date() >
new Date((subscription.canceled_at + secondsInTwoWeeks) * 1000)
);
return subscription?.items?.data[0].plan && isNotCanceledOrHasTime;
}
);
return (
customer.email?.toLowerCase() === email.toLowerCase() &&
hasValidSubscription
);
});
return {
isSubscriber,
subscriptionCanceledAt,
};
}

View File

@ -0,0 +1,52 @@
import Stripe from "stripe";
const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID;
const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID;
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function checkSubscriptionByEmail(email: string) {
let active: boolean | undefined,
stripeSubscriptionId: string | undefined,
currentPeriodStart: number | undefined,
currentPeriodEnd: number | undefined;
if (!STRIPE_SECRET_KEY)
return {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
};
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
console.log("Request made to Stripe by:", email);
const listByEmail = await stripe.customers.list({
email: email.toLowerCase(),
expand: ["data.subscriptions"],
});
listByEmail.data.some((customer) => {
customer.subscriptions?.data.some((subscription) => {
subscription.current_period_end;
active = subscription.items.data.some(
(e) =>
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
);
stripeSubscriptionId = subscription.id;
currentPeriodStart = subscription.current_period_start * 1000;
currentPeriodEnd = subscription.current_period_end * 1000;
});
});
return {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
};
}

View File

@ -59,6 +59,7 @@ export default async function updateCollection(
include: { include: {
user: { user: {
select: { select: {
image: true,
username: true, username: true,
name: true, name: true,
id: true, id: true,

View File

@ -18,6 +18,7 @@ export default async function getCollection(userId: number) {
select: { select: {
username: true, username: true,
name: true, name: true,
image: true,
}, },
}, },
}, },

View File

@ -42,6 +42,15 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
}); });
} }
if (query.searchByTextContent) {
searchConditions.push({
textContent: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByTags) { if (query.searchByTags) {
searchConditions.push({ searchConditions.push({
tags: { tags: {

View File

@ -31,6 +31,9 @@ export default async function deleteLink(userId: number, linkId: number) {
removeFile({ removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`, filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
}); });
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
return { response: deleteLink, status: 200 }; return { response: deleteLink, status: 200 };
} }

View File

@ -0,0 +1,48 @@
import { prisma } from "@/lib/api/db";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
export default async function getLinkById(userId: number, linkId: number) {
if (!linkId)
return {
response: "Please choose a valid link.",
status: 401,
};
const collectionIsAccessible = (await getPermission({ userId, linkId })) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId
);
const isCollectionOwner = collectionIsAccessible?.ownerId === userId;
if (collectionIsAccessible?.ownerId !== userId && !memberHasAccess)
return {
response: "Collection is not accessible.",
status: 401,
};
else {
const updatedLink = await prisma.link.findUnique({
where: {
id: linkId,
},
include: {
tags: true,
collection: true,
pinnedBy: isCollectionOwner
? {
where: { id: userId },
select: { id: true },
}
: undefined,
},
});
return { response: updatedLink, status: 200 };
}
}

View File

@ -4,7 +4,7 @@ import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import moveFile from "@/lib/api/storage/moveFile"; import moveFile from "@/lib/api/storage/moveFile";
export default async function updateLink( export default async function updateLinkById(
userId: number, userId: number,
linkId: number, linkId: number,
data: LinkIncludingShortenedCollectionAndTags data: LinkIncludingShortenedCollectionAndTags
@ -102,6 +102,11 @@ export default async function updateLink(
`archives/${collectionIsAccessible?.id}/${linkId}.png`, `archives/${collectionIsAccessible?.id}/${linkId}.png`,
`archives/${data.collection.id}/${linkId}.png` `archives/${data.collection.id}/${linkId}.png`
); );
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
`archives/${data.collection.id}/${linkId}_readability.json`
);
} }
return { response: updatedLink, status: 200 }; return { response: updatedLink, status: 200 };

View File

@ -56,6 +56,7 @@ export default async function postLink(
url: link.url, url: link.url,
name: link.name, name: link.name,
description, description,
readabilityPath: "pending",
collection: { collection: {
connectOrCreate: { connectOrCreate: {
where: { where: {

View File

@ -18,7 +18,7 @@ export default async function exportData(userId: number) {
if (!user) return { response: "User not found.", status: 404 }; if (!user) return { response: "User not found.", status: 404 };
const { password, id, image, ...userData } = user; const { password, id, ...userData } = user;
function redactIds(obj: any) { function redactIds(obj: any) {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {

View File

@ -7,94 +7,97 @@ export default async function importFromHTMLFile(
userId: number, userId: number,
rawData: string rawData: string
) { ) {
try { const dom = new JSDOM(rawData);
const dom = new JSDOM(rawData); const document = dom.window.document;
const document = dom.window.document;
const folders = document.querySelectorAll("H3"); const folders = document.querySelectorAll("H3");
// @ts-ignore await prisma
for (const folder of folders) { .$transaction(
const findCollection = await prisma.user.findUnique({ async () => {
where: { // @ts-ignore
id: userId, for (const folder of folders) {
}, const findCollection = await prisma.user.findUnique({
select: {
collections: {
where: { where: {
name: folder.textContent.trim(), id: userId,
}, },
}, select: {
}, collections: {
}); where: {
name: folder.textContent.trim(),
},
},
},
});
const checkIfCollectionExists = findCollection?.collections[0]; const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id; let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists || !collectionId) { if (!checkIfCollectionExists || !collectionId) {
const newCollection = await prisma.collection.create({ const newCollection = await prisma.collection.create({
data: { data: {
name: folder.textContent.trim(), name: folder.textContent.trim(),
description: "", description: "",
color: "#0ea5e9", color: "#0ea5e9",
isPublic: false, isPublic: false,
ownerId: userId, ownerId: userId,
}, },
}); });
createFolder({ filePath: `archives/${newCollection.id}` }); createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id; collectionId = newCollection.id;
} }
createFolder({ filePath: `archives/${collectionId}` }); createFolder({ filePath: `archives/${collectionId}` });
const bookmarks = folder.nextElementSibling.querySelectorAll("A"); const bookmarks = folder.nextElementSibling.querySelectorAll("A");
for (const bookmark of bookmarks) { for (const bookmark of bookmarks) {
await prisma.link.create({ await prisma.link.create({
data: { data: {
name: bookmark.textContent.trim(), name: bookmark.textContent.trim(),
url: bookmark.getAttribute("HREF"), url: bookmark.getAttribute("HREF"),
tags: bookmark.getAttribute("TAGS") tags: bookmark.getAttribute("TAGS")
? { ? {
connectOrCreate: bookmark connectOrCreate: bookmark
.getAttribute("TAGS") .getAttribute("TAGS")
.split(",") .split(",")
.map((tag: string) => .map((tag: string) =>
tag tag
? { ? {
where: { where: {
name_ownerId: { name_ownerId: {
name: tag.trim(), name: tag.trim(),
ownerId: userId, ownerId: userId,
}, },
},
create: {
name: tag.trim(),
owner: {
connect: {
id: userId,
}, },
}, create: {
}, name: tag.trim(),
} owner: {
: undefined connect: {
), id: userId,
} },
: undefined, },
description: bookmark.getAttribute("DESCRIPTION") },
? bookmark.getAttribute("DESCRIPTION") }
: "", : undefined
collectionId: collectionId, ),
createdAt: new Date(), }
}, : undefined,
}); description: bookmark.getAttribute("DESCRIPTION")
} ? bookmark.getAttribute("DESCRIPTION")
} : "",
} catch (err) { collectionId: collectionId,
console.log(err); createdAt: new Date(),
} },
});
}
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
return { response: "Success.", status: 200 }; return { response: "Success.", status: 200 };
} }

View File

@ -5,87 +5,88 @@ import createFolder from "@/lib/api/storage/createFolder";
export default async function getData(userId: number, rawData: string) { export default async function getData(userId: number, rawData: string) {
const data: Backup = JSON.parse(rawData); const data: Backup = JSON.parse(rawData);
console.log(typeof data); await prisma
.$transaction(
async () => {
// Import collections
for (const e of data.collections) {
e.name = e.name.trim();
// Import collections const findCollection = await prisma.user.findUnique({
try {
for (const e of data.collections) {
e.name = e.name.trim();
const findCollection = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
collections: {
where: { where: {
name: e.name, id: userId,
}, },
}, select: {
}, collections: {
});
const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists) {
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
// Import Links
for (const link of e.links) {
const newLink = await prisma.link.create({
data: {
url: link.url,
name: link.name,
description: link.description,
collection: {
connect: {
id: collectionId,
},
},
// Import Tags
tags: {
connectOrCreate: link.tags.map((tag) => ({
where: { where: {
name_ownerId: { name: e.name,
name: tag.name.trim(),
ownerId: userId,
},
}, },
create: { },
name: tag.name.trim(),
owner: {
connect: {
id: userId,
},
},
},
})),
}, },
}, });
});
} const checkIfCollectionExists = findCollection?.collections[0];
}
} catch (err) { let collectionId = findCollection?.collections[0]?.id;
console.log(err);
} if (!checkIfCollectionExists) {
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
// Import Links
for (const link of e.links) {
const newLink = await prisma.link.create({
data: {
url: link.url,
name: link.name,
description: link.description,
collection: {
connect: {
id: collectionId,
},
},
// Import Tags
tags: {
connectOrCreate: link.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name.trim(),
ownerId: userId,
},
},
create: {
name: tag.name.trim(),
owner: {
connect: {
id: userId,
},
},
},
})),
},
},
});
}
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
return { response: "Success.", status: 200 }; return { response: "Success.", status: 200 };
} }

View File

@ -3,7 +3,7 @@ import { prisma } from "@/lib/api/db";
export default async function getPublicUserById( export default async function getPublicUserById(
targetId: number | string, targetId: number | string,
isId: boolean, isId: boolean,
requestingUsername?: string requestingId?: number
) { ) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: isId where: isId
@ -29,19 +29,32 @@ export default async function getPublicUserById(
(usernames) => usernames.username (usernames) => usernames.username
); );
if ( if (user?.isPrivate) {
user?.isPrivate && if (requestingId) {
(!requestingUsername || const requestingUsername = (
!whitelistedUsernames.includes(requestingUsername?.toLowerCase())) await prisma.user.findUnique({ where: { id: requestingId } })
) { )?.username;
return { response: "User not found or profile is private.", status: 404 };
if (
!requestingUsername ||
!whitelistedUsernames.includes(requestingUsername?.toLowerCase())
) {
return {
response: "User not found or profile is private.",
status: 404,
};
}
} else
return { response: "User not found or profile is private.", status: 404 };
} }
const { password, ...lessSensitiveInfo } = user; const { password, ...lessSensitiveInfo } = user;
const data = { const data = {
id: lessSensitiveInfo.id,
name: lessSensitiveInfo.name, name: lessSensitiveInfo.name,
username: lessSensitiveInfo.username, username: lessSensitiveInfo.username,
image: lessSensitiveInfo.image,
}; };
return { response: data, status: 200 }; return { response: data, status: 200 };

View File

@ -0,0 +1,26 @@
import { prisma } from "@/lib/api/db";
export default async function deleteTagById(userId: number, tagId: number) {
if (!tagId)
return { response: "Please choose a valid name for the tag.", status: 401 };
const targetTag = await prisma.tag.findUnique({
where: {
id: tagId,
},
});
if (targetTag?.ownerId !== userId)
return {
response: "Permission denied.",
status: 401,
};
const updatedTag = await prisma.tag.delete({
where: {
id: tagId,
},
});
return { response: updatedTag, status: 200 };
}

View File

@ -1,7 +1,7 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { Tag } from "@prisma/client"; import { Tag } from "@prisma/client";
export default async function updateTag( export default async function updeteTagById(
userId: number, userId: number,
tagId: number, tagId: number,
data: Tag data: Tag

View File

@ -32,50 +32,61 @@ export default async function deleteUserById(
} }
// Delete the user and all related data within a transaction // Delete the user and all related data within a transaction
await prisma.$transaction(async (prisma) => { await prisma
// Delete whitelisted users .$transaction(
await prisma.whitelistedUser.deleteMany({ async (prisma) => {
where: { userId }, // Delete whitelisted users
}); await prisma.whitelistedUser.deleteMany({
where: { userId },
});
// Delete links // Delete links
await prisma.link.deleteMany({ await prisma.link.deleteMany({
where: { collection: { ownerId: userId } }, where: { collection: { ownerId: userId } },
}); });
// Delete tags // Delete tags
await prisma.tag.deleteMany({ await prisma.tag.deleteMany({
where: { ownerId: userId }, where: { ownerId: userId },
}); });
// Find collections that the user owns // Find collections that the user owns
const collections = await prisma.collection.findMany({ const collections = await prisma.collection.findMany({
where: { ownerId: userId }, where: { ownerId: userId },
}); });
for (const collection of collections) { for (const collection of collections) {
// Delete related users and collections relations // Delete related users and collections relations
await prisma.usersAndCollections.deleteMany({ await prisma.usersAndCollections.deleteMany({
where: { collectionId: collection.id }, where: { collectionId: collection.id },
}); });
// Delete archive folders associated with collections // Delete archive folders
removeFolder({ filePath: `archives/${collection.id}` }); removeFolder({ filePath: `archives/${collection.id}` });
} }
// Delete collections after cleaning up related data // Delete collections after cleaning up related data
await prisma.collection.deleteMany({ await prisma.collection.deleteMany({
where: { ownerId: userId }, where: { ownerId: userId },
}); });
// Delete user's avatar // Delete subscription
removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); if (process.env.STRIPE_SECRET_KEY)
await prisma.subscription.delete({
where: { userId },
});
// Finally, delete the user // Delete user's avatar
await prisma.user.delete({ await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
where: { id: userId },
}); // Finally, delete the user
}); await prisma.user.delete({
where: { id: userId },
});
},
{ timeout: 20000 }
)
.catch((err) => console.log(err));
if (process.env.STRIPE_SECRET_KEY) { if (process.env.STRIPE_SECRET_KEY) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {

View File

@ -11,6 +11,7 @@ export default async function getUserById(userId: number) {
username: true, username: true,
}, },
}, },
subscriptions: true,
}, },
}); });
@ -21,11 +22,14 @@ export default async function getUserById(userId: number) {
(usernames) => usernames.username (usernames) => usernames.username
); );
const { password, ...lessSensitiveInfo } = user; const { password, subscriptions, ...lessSensitiveInfo } = user;
const data = { const data = {
...lessSensitiveInfo, ...lessSensitiveInfo,
whitelistedUsers: whitelistedUsernames, whitelistedUsers: whitelistedUsernames,
subscription: {
active: subscriptions?.active,
},
}; };
return { response: data, status: 200 }; return { response: data, status: 200 };

View File

@ -10,12 +10,7 @@ const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
export default async function updateUserById( export default async function updateUserById(
sessionUser: { userId: number,
id: number;
username: string;
email: string;
isSubscriber: boolean;
},
data: AccountSettings data: AccountSettings
) { ) {
if (emailEnabled && !data.email) if (emailEnabled && !data.email)
@ -49,7 +44,7 @@ export default async function updateUserById(
const userIsTaken = await prisma.user.findFirst({ const userIsTaken = await prisma.user.findFirst({
where: { where: {
id: { not: sessionUser.id }, id: { not: userId },
OR: emailEnabled OR: emailEnabled
? [ ? [
{ {
@ -89,17 +84,15 @@ export default async function updateUserById(
// Avatar Settings // Avatar Settings
const profilePic = data.profilePic; if (data.image?.startsWith("data:image/jpeg;base64")) {
if (data.image.length < 1572864) {
if (profilePic.startsWith("data:image/jpeg;base64")) {
if (data.profilePic.length < 1572864) {
try { try {
const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, ""); const base64Data = data.image.replace(/^data:image\/jpeg;base64,/, "");
createFolder({ filePath: `uploads/avatar` }); createFolder({ filePath: `uploads/avatar` });
await createFile({ await createFile({
filePath: `uploads/avatar/${sessionUser.id}.jpg`, filePath: `uploads/avatar/${userId}.jpg`,
data: base64Data, data: base64Data,
isBase64: true, isBase64: true,
}); });
@ -113,10 +106,14 @@ export default async function updateUserById(
status: 400, status: 400,
}; };
} }
} else if (profilePic == "") { } else if (data.image == "") {
removeFile({ filePath: `uploads/avatar/${sessionUser.id}.jpg` }); removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
} }
const previousEmail = (
await prisma.user.findUnique({ where: { id: userId } })
)?.email;
// Other settings // Other settings
const saltRounds = 10; const saltRounds = 10;
@ -124,13 +121,14 @@ export default async function updateUserById(
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { where: {
id: sessionUser.id, id: userId,
}, },
data: { data: {
name: data.name, name: data.name,
username: data.username.toLowerCase().trim(), username: data.username.toLowerCase().trim(),
email: data.email?.toLowerCase().trim(), email: data.email?.toLowerCase().trim(),
isPrivate: data.isPrivate, isPrivate: data.isPrivate,
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
archiveAsScreenshot: data.archiveAsScreenshot, archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsPDF: data.archiveAsPDF, archiveAsPDF: data.archiveAsPDF,
archiveAsWaybackMachine: data.archiveAsWaybackMachine, archiveAsWaybackMachine: data.archiveAsWaybackMachine,
@ -141,10 +139,12 @@ export default async function updateUserById(
}, },
include: { include: {
whitelistedUsers: true, whitelistedUsers: true,
subscriptions: true,
}, },
}); });
const { whitelistedUsers, password, ...userInfo } = updatedUser; const { whitelistedUsers, password, subscriptions, ...userInfo } =
updatedUser;
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed // If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
const newWhitelistedUsernames: string[] = data.whitelistedUsers || []; const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
@ -168,7 +168,7 @@ export default async function updateUserById(
// Delete whitelistedUsers that are not present in the new list // Delete whitelistedUsers that are not present in the new list
await prisma.whitelistedUser.deleteMany({ await prisma.whitelistedUser.deleteMany({
where: { where: {
userId: sessionUser.id, userId: userId,
username: { username: {
in: usernamesToDelete, in: usernamesToDelete,
}, },
@ -180,24 +180,25 @@ export default async function updateUserById(
await prisma.whitelistedUser.create({ await prisma.whitelistedUser.create({
data: { data: {
username, username,
userId: sessionUser.id, userId: userId,
}, },
}); });
} }
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (STRIPE_SECRET_KEY && emailEnabled && sessionUser.email !== data.email) if (STRIPE_SECRET_KEY && emailEnabled && previousEmail !== data.email)
await updateCustomerEmail( await updateCustomerEmail(
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
sessionUser.email, previousEmail as string,
data.email as string data.email as string
); );
const response: Omit<AccountSettings, "password"> = { const response: Omit<AccountSettings, "password"> = {
...userInfo, ...userInfo,
whitelistedUsers: newWhitelistedUsernames, whitelistedUsers: newWhitelistedUsernames,
profilePic: `/api/v1/avatar/${userInfo.id}?${Date.now()}`, image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
subscription: { active: subscriptions?.active },
}; };
return { response, status: 200 }; return { response, status: 200 };

View File

@ -0,0 +1,122 @@
const { S3 } = require("@aws-sdk/client-s3");
const { PrismaClient } = require("@prisma/client");
const { existsSync } = require("fs");
const util = require("util");
const prisma = new PrismaClient();
const STORAGE_FOLDER = process.env.STORAGE_FOLDER || "data";
const s3Client =
process.env.SPACES_ENDPOINT &&
process.env.SPACES_REGION &&
process.env.SPACES_KEY &&
process.env.SPACES_SECRET
? new S3({
forcePathStyle: false,
endpoint: process.env.SPACES_ENDPOINT,
region: process.env.SPACES_REGION,
credentials: {
accessKeyId: process.env.SPACES_KEY,
secretAccessKey: process.env.SPACES_SECRET,
},
})
: undefined;
async function checkFileExistence(path) {
if (s3Client) {
const bucketParams = {
Bucket: process.env.BUCKET_NAME,
Key: path,
};
try {
const headObjectAsync = util.promisify(
s3Client.headObject.bind(s3Client)
);
try {
await headObjectAsync(bucketParams);
return true;
} catch (err) {
return false;
}
} catch (err) {
console.log("Error:", err);
return false;
}
} else {
try {
if (existsSync(STORAGE_FOLDER + "/" + path)) {
return true;
} else return false;
} catch (err) {
console.log(err);
}
}
}
// Avatars
async function migrateToV2() {
const users = await prisma.user.findMany();
for (let user of users) {
const path = `uploads/avatar/${user.id}.jpg`;
const res = await checkFileExistence(path);
if (res) {
await prisma.user.update({
where: { id: user.id },
data: { image: path },
});
console.log(`Updated avatar for avatar ${user.id}`);
} else {
console.log(`No avatar found for avatar ${user.id}`);
}
}
const links = await prisma.link.findMany();
// PDFs
for (let link of links) {
const path = `archives/${link.collectionId}/${link.id}.pdf`;
const res = await checkFileExistence(path);
if (res) {
await prisma.link.update({
where: { id: link.id },
data: { pdfPath: path },
});
console.log(`Updated capture for pdf ${link.id}`);
} else {
console.log(`No capture found for pdf ${link.id}`);
}
}
// Screenshots
for (let link of links) {
const path = `archives/${link.collectionId}/${link.id}.png`;
const res = await checkFileExistence(path);
if (res) {
await prisma.link.update({
where: { id: link.id },
data: { screenshotPath: path },
});
console.log(`Updated capture for screenshot ${link.id}`);
} else {
console.log(`No capture found for screenshot ${link.id}`);
}
}
await prisma.$disconnect();
}
migrateToV2().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -14,12 +14,12 @@ export default async function paymentCheckout(
expand: ["data.subscriptions"], expand: ["data.subscriptions"],
}); });
const isExistingCostomer = listByEmail?.data[0]?.id || undefined; const isExistingCustomer = listByEmail?.data[0]?.id || undefined;
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS = const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS; process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
customer: isExistingCostomer ? isExistingCostomer : undefined, customer: isExistingCustomer ? isExistingCustomer : undefined,
line_items: [ line_items: [
{ {
price: priceId, price: priceId,
@ -27,7 +27,7 @@ export default async function paymentCheckout(
}, },
], ],
mode: "subscription", mode: "subscription",
customer_email: isExistingCostomer ? undefined : email.toLowerCase(), customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`, success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL}/login`, cancel_url: `${process.env.BASE_URL}/login`,
automatic_tax: { automatic_tax: {

View File

@ -9,14 +9,13 @@ import s3Client from "./s3Client";
import util from "util"; import util from "util";
type ReturnContentTypes = type ReturnContentTypes =
| "text/html" | "text/plain"
| "image/jpeg" | "image/jpeg"
| "image/png" | "image/png"
| "application/pdf"; | "application/pdf"
| "application/json";
export default async function readFile(filePath: string) { export default async function readFile(filePath: string) {
const isRequestingAvatar = filePath.startsWith("uploads/avatar");
let contentType: ReturnContentTypes; let contentType: ReturnContentTypes;
if (s3Client) { if (s3Client) {
@ -41,12 +40,12 @@ export default async function readFile(filePath: string) {
try { try {
await headObjectAsync(bucketParams); await headObjectAsync(bucketParams);
} catch (err) { } catch (err) {
contentType = "text/html"; contentType = "text/plain";
returnObject = { returnObject = {
file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate, file: "File not found.",
contentType, contentType,
status: isRequestingAvatar ? 200 : 400, status: 400,
}; };
} }
@ -60,6 +59,8 @@ export default async function readFile(filePath: string) {
contentType = "application/pdf"; contentType = "application/pdf";
} else if (filePath.endsWith(".png")) { } else if (filePath.endsWith(".png")) {
contentType = "image/png"; contentType = "image/png";
} else if (filePath.endsWith("_readability.json")) {
contentType = "application/json";
} else { } else {
// if (filePath.endsWith(".jpg")) // if (filePath.endsWith(".jpg"))
contentType = "image/jpeg"; contentType = "image/jpeg";
@ -71,9 +72,9 @@ export default async function readFile(filePath: string) {
} catch (err) { } catch (err) {
console.log("Error:", err); console.log("Error:", err);
contentType = "text/html"; contentType = "text/plain";
return { return {
file: "An internal occurred, please contact support.", file: "An internal occurred, please contact the support team.",
contentType, contentType,
}; };
} }
@ -85,6 +86,8 @@ export default async function readFile(filePath: string) {
contentType = "application/pdf"; contentType = "application/pdf";
} else if (filePath.endsWith(".png")) { } else if (filePath.endsWith(".png")) {
contentType = "image/png"; contentType = "image/png";
} else if (filePath.endsWith("_readability.json")) {
contentType = "application/json";
} else { } else {
// if (filePath.endsWith(".jpg")) // if (filePath.endsWith(".jpg"))
contentType = "image/jpeg"; contentType = "image/jpeg";
@ -92,9 +95,9 @@ export default async function readFile(filePath: string) {
if (!fs.existsSync(creationPath)) if (!fs.existsSync(creationPath))
return { return {
file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate, file: "File not found.",
contentType: "text/html", contentType: "text/plain",
status: isRequestingAvatar ? 200 : 400, status: 400,
}; };
else { else {
const file = fs.readFileSync(creationPath); const file = fs.readFileSync(creationPath);

View File

@ -0,0 +1,75 @@
import { prisma } from "./db";
import { Subscription, User } from "@prisma/client";
import checkSubscriptionByEmail from "./checkSubscriptionByEmail";
interface UserIncludingSubscription extends User {
subscriptions: Subscription | null;
}
export default async function verifySubscription(
user?: UserIncludingSubscription
) {
if (!user) {
return null;
}
const subscription = user.subscriptions;
const currentDate = new Date();
if (
subscription &&
currentDate > subscription.currentPeriodEnd &&
!subscription.active
) {
return null;
}
if (!subscription || currentDate > subscription.currentPeriodEnd) {
const {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
} = await checkSubscriptionByEmail(user.email as string);
if (
active &&
stripeSubscriptionId &&
currentPeriodStart &&
currentPeriodEnd
) {
await prisma.subscription
.upsert({
where: {
userId: user.id,
},
create: {
active,
stripeSubscriptionId,
currentPeriodStart: new Date(currentPeriodStart),
currentPeriodEnd: new Date(currentPeriodEnd),
userId: user.id,
},
update: {
active,
stripeSubscriptionId,
currentPeriodStart: new Date(currentPeriodStart),
currentPeriodEnd: new Date(currentPeriodEnd),
},
})
.catch((err) => console.log(err));
}
if (!active) {
if (user.username)
// await prisma.user.update({
// where: { id: user.id },
// data: { username: null },
// });
return null;
}
}
return user;
}

60
lib/api/verifyUser.ts Normal file
View File

@ -0,0 +1,60 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
import { prisma } from "./db";
import { User } from "@prisma/client";
import verifySubscription from "./verifySubscription";
type Props = {
req: NextApiRequest;
res: NextApiResponse;
};
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function verifyUser({
req,
res,
}: Props): Promise<User | null> {
const token = await getToken({ req });
const userId = token?.id;
if (!userId) {
res.status(401).json({ response: "You must be logged in." });
return null;
}
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
subscriptions: true,
},
});
if (!user) {
res.status(404).json({ response: "User not found." });
return null;
}
if (!user.username) {
res.status(401).json({
response: "Username not found.",
});
return null;
}
if (STRIPE_SECRET_KEY) {
const subscribedUser = verifySubscription(user);
if (!subscribedUser) {
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app if you think this is an issue.",
});
return null;
}
}
return user;
}

View File

@ -27,14 +27,15 @@ const addMemberToCollection = async (
if (user.username) { if (user.username) {
setMember({ setMember({
collectionId: collection.id, collectionId: collection.id,
userId: user.id,
canCreate: false, canCreate: false,
canUpdate: false, canUpdate: false,
canDelete: false, canDelete: false,
userId: user.id,
user: { user: {
id: user.id, id: user.id,
name: user.name, name: user.name,
username: user.username, username: user.username,
image: user.image,
}, },
}); });
} }

View File

@ -1,13 +0,0 @@
const avatarCache = new Map();
export default async function avatarExists(fileUrl: string): Promise<boolean> {
if (avatarCache.has(fileUrl)) {
return avatarCache.get(fileUrl);
}
const response = await fetch(fileUrl, { method: "HEAD" });
const exists = !(response.headers.get("content-type") === "text/html");
avatarCache.set(fileUrl, exists);
return exists;
}

View File

@ -0,0 +1,16 @@
export default async function getLatestVersion(setShowAnnouncement: Function) {
const announcementId = localStorage.getItem("announcementId");
const response = await fetch(
`https://blog.linkwarden.app/latest-announcement.json`
);
const data = await response.json();
const latestAnnouncement = data.id;
if (announcementId !== latestAnnouncement) {
setShowAnnouncement(true);
localStorage.setItem("announcementId", latestAnnouncement);
}
}

View File

@ -1,7 +1,7 @@
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
export default async function getPublicUserData(id: number | string) { export default async function getPublicUserData(id: number | string) {
const response = await fetch(`/api/v1/users/${id}`); const response = await fetch(`/api/v1/public/users/${id}`);
const data = await response.json(); const data = await response.json();

View File

@ -3,6 +3,7 @@ const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
images: { images: {
domains: ["t2.gstatic.com"], domains: ["t2.gstatic.com"],
minimumCacheTTL: 10,
}, },
}; };

View File

@ -21,6 +21,7 @@
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@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",
"@next/font": "13.4.9", "@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",
@ -32,12 +33,14 @@
"axios": "^1.5.1", "axios": "^1.5.1",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"colorthief": "^2.4.0", "colorthief": "^2.4.0",
"crypto-js": "^4.1.1", "crypto-js": "^4.2.0",
"csstype": "^3.1.2", "csstype": "^3.1.2",
"dompurify": "^3.0.6",
"eslint": "8.46.0", "eslint": "8.46.0",
"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",
"micro": "^10.0.1",
"next": "13.4.12", "next": "13.4.12",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
@ -57,6 +60,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.35.1", "@playwright/test": "^1.35.1",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/dompurify": "^3.0.4",
"@types/jsdom": "^21.1.3", "@types/jsdom": "^21.1.3",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.26", "postcss": "^8.4.26",

View File

@ -22,7 +22,11 @@ export default function App({
}, []); }, []);
return ( return (
<SessionProvider session={pageProps.session} basePath="/api/v1/auth"> <SessionProvider
session={pageProps.session}
refetchOnWindowFocus={false}
basePath="/api/v1/auth"
>
<Head> <Head>
<title>Linkwarden</title> <title>Linkwarden</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@ -1,28 +1,20 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
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";
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 });
if (!user) return;
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 session = await getServerSession(req, res, authOptions);
if (!session?.user?.username)
return res.status(401).json({ response: "You must be logged in." });
else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
const collectionIsAccessible = await getPermission({ const collectionIsAccessible = await getPermission({
userId: session.user.id, userId: user.id,
collectionId: Number(collectionId), collectionId: Number(collectionId),
}); });

View File

@ -1,24 +1,26 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import NextAuth from "next-auth/next"; import NextAuth from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials"; import CredentialsProvider from "next-auth/providers/credentials";
import { AuthOptions, Session } from "next-auth"; import { AuthOptions } from "next-auth";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import EmailProvider from "next-auth/providers/email"; import EmailProvider from "next-auth/providers/email";
import { JWT } from "next-auth/jwt";
import { PrismaAdapter } from "@auth/prisma-adapter"; import { PrismaAdapter } from "@auth/prisma-adapter";
import { Adapter } from "next-auth/adapters"; import { Adapter } from "next-auth/adapters";
import sendVerificationRequest from "@/lib/api/sendVerificationRequest"; import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
import { Provider } from "next-auth/providers"; import { Provider } from "next-auth/providers";
import checkSubscription from "@/lib/api/checkSubscription"; import verifySubscription from "@/lib/api/verifySubscription";
const emailEnabled = const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const providers: Provider[] = [ const providers: Provider[] = [
CredentialsProvider({ CredentialsProvider({
type: "credentials", type: "credentials",
credentials: {}, credentials: {},
async authorize(credentials, req) { async authorize(credentials, req) {
console.log("User log in attempt...");
if (!credentials) return null; if (!credentials) return null;
const { username, password } = credentials as { const { username, password } = credentials as {
@ -26,7 +28,7 @@ const providers: Provider[] = [
password: string; password: string;
}; };
const findUser = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: emailEnabled where: emailEnabled
? { ? {
OR: [ OR: [
@ -46,12 +48,12 @@ const providers: Provider[] = [
let passwordMatches: boolean = false; let passwordMatches: boolean = false;
if (findUser?.password) { if (user?.password) {
passwordMatches = bcrypt.compareSync(password, findUser.password); passwordMatches = bcrypt.compareSync(password, user.password);
} }
if (passwordMatches) { if (passwordMatches) {
return findUser; return { id: user?.id };
} else return null as any; } else return null as any;
}, },
}), }),
@ -81,64 +83,31 @@ export const authOptions: AuthOptions = {
verifyRequest: "/confirmation", verifyRequest: "/confirmation",
}, },
callbacks: { callbacks: {
session: async ({ session, token }: { session: Session; token: JWT }) => {
session.user.id = parseInt(token.id as string);
session.user.username = token.username as string;
session.user.isSubscriber = token.isSubscriber as boolean;
return session;
},
async jwt({ token, trigger, user }) { async jwt({ token, trigger, user }) {
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; token.sub = token.sub ? Number(token.sub) : undefined;
if (trigger === "signIn") token.id = user?.id as number;
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS = return token;
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS; },
const secondsInTwoWeeks = NEXT_PUBLIC_TRIAL_PERIOD_DAYS async session({ session, token }) {
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS) * 86400 session.user.id = token.id;
: 1209600;
const subscriptionIsTimesUp =
token.subscriptionCanceledAt &&
new Date() >
new Date(
((token.subscriptionCanceledAt as number) + secondsInTwoWeeks) *
1000
);
if (
STRIPE_SECRET_KEY &&
(trigger || subscriptionIsTimesUp || !token.isSubscriber)
) {
const subscription = await checkSubscription(
STRIPE_SECRET_KEY,
token.email as string
);
if (subscription.subscriptionCanceledAt) {
token.subscriptionCanceledAt = subscription.subscriptionCanceledAt;
} else token.subscriptionCanceledAt = undefined;
token.isSubscriber = subscription.isSubscriber;
}
if (trigger === "signIn") {
token.id = user.id;
token.username = (user as any).username;
} else if (trigger === "update" && token.id) {
console.log(token);
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: token.id as number, id: token.id,
},
include: {
subscriptions: true,
}, },
}); });
if (user) { if (user) {
token.name = user.name; const subscribedUser = await verifySubscription(user);
token.username = user.username?.toLowerCase();
token.email = user.email?.toLowerCase();
} }
} }
return token;
return session;
}, },
}, },
}; };

View File

@ -1,34 +1,21 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
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";
export default async function Index(req: NextApiRequest, res: NextApiResponse) { export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
const userId = session?.user.id;
const username = session?.user.username?.toLowerCase();
const queryId = Number(req.query.id); const queryId = Number(req.query.id);
if (!userId || !username) const user = await verifyUser({ req, res });
return res if (!user) return;
.setHeader("Content-Type", "text/html")
.status(401)
.send("You must be logged in.");
else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (!queryId) if (!queryId)
return res return res
.setHeader("Content-Type", "text/html") .setHeader("Content-Type", "text/plain")
.status(401) .status(401)
.send("Invalid parameters."); .send("Invalid parameters.");
if (userId !== queryId) { if (user.id !== queryId) {
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({
where: { where: {
id: queryId, id: queryId,
@ -42,10 +29,15 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
(whitelistedUsername) => whitelistedUsername.username (whitelistedUsername) => whitelistedUsername.username
); );
if (targetUser?.isPrivate && !whitelistedUsernames?.includes(username)) { if (
targetUser?.isPrivate &&
user.username &&
!whitelistedUsernames?.includes(user.username)
) {
return res return res
.setHeader("Content-Type", "text/html") .setHeader("Content-Type", "text/plain")
.send("This profile is private."); .status(400)
.send("File not found.");
} }
} }

View File

@ -1,33 +1,25 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById"; import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById";
import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById"; import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById";
import verifyUser from "@/lib/api/verifyUser";
export default async function collections( export default async function collections(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse
) { ) {
const session = await getServerSession(req, res, authOptions); const user = await verifyUser({ req, res });
if (!user) return;
if (!session?.user?.id) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "PUT") { if (req.method === "PUT") {
const updated = await updateCollectionById( const updated = await updateCollectionById(
session.user.id, user.id,
Number(req.query.id) as number, Number(req.query.id) as number,
req.body req.body
); );
return res.status(updated.status).json({ response: updated.response }); return res.status(updated.status).json({ response: updated.response });
} else if (req.method === "DELETE") { } else if (req.method === "DELETE") {
const deleted = await deleteCollectionById( const deleted = await deleteCollectionById(
session.user.id, user.id,
Number(req.query.id) as number Number(req.query.id) as number
); );
return res.status(deleted.status).json({ response: deleted.response }); return res.status(deleted.status).json({ response: deleted.response });

View File

@ -1,30 +1,22 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import getCollections from "@/lib/api/controllers/collections/getCollections"; import getCollections from "@/lib/api/controllers/collections/getCollections";
import postCollection from "@/lib/api/controllers/collections/postCollection"; import postCollection from "@/lib/api/controllers/collections/postCollection";
import verifyUser from "@/lib/api/verifyUser";
export default async function collections( export default async function collections(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse
) { ) {
const session = await getServerSession(req, res, authOptions); const user = await verifyUser({ req, res });
if (!user) return;
if (!session?.user?.id) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "GET") { if (req.method === "GET") {
const collections = await getCollections(session.user.id); const collections = await getCollections(user.id);
return res return res
.status(collections.status) .status(collections.status)
.json({ response: collections.response }); .json({ response: collections.response });
} else if (req.method === "POST") { } else if (req.method === "POST") {
const newCollection = await postCollection(req.body, session.user.id); const newCollection = await postCollection(req.body, user.id);
return res return res
.status(newCollection.status) .status(newCollection.status)
.json({ response: newCollection.response }); .json({ response: newCollection.response });

View File

@ -1,19 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import { LinkRequestQuery } from "@/types/global"; import { LinkRequestQuery } from "@/types/global";
import getDashboardData from "@/lib/api/controllers/dashboard/getDashboardData"; import getDashboardData from "@/lib/api/controllers/dashboard/getDashboardData";
import verifyUser from "@/lib/api/verifyUser";
export default async function links(req: NextApiRequest, res: NextApiResponse) { export default async function links(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const user = await verifyUser({ req, res });
if (!user) return;
if (!session?.user?.id) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "GET") { if (req.method === "GET") {
const convertedData: LinkRequestQuery = { const convertedData: LinkRequestQuery = {
@ -21,7 +13,7 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
cursor: req.query.cursor ? Number(req.query.cursor as string) : undefined, cursor: req.query.cursor ? Number(req.query.cursor as string) : undefined,
}; };
const links = await getDashboardData(session.user.id, convertedData); const links = await getDashboardData(user.id, convertedData);
return res.status(links.status).json({ response: links.response }); return res.status(links.status).json({ response: links.response });
} }
} }

View File

@ -1,16 +0,0 @@
// For future...
// import { getToken } from "next-auth/jwt";
// export default async (req, res) => {
// // If you don't have NEXTAUTH_SECRET set, you will have to pass your secret as `secret` to `getToken`
// console.log({ req });
// const token = await getToken({ req, raw: true });
// if (token) {
// // Signed in
// console.log("JSON Web Token", JSON.stringify(token, null, 2));
// } else {
// // Not Signed in
// res.status(401);
// }
// res.end();
// };

View File

@ -1,33 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import deleteLinkById from "@/lib/api/controllers/links/linkId/deleteLinkById";
import updateLinkById from "@/lib/api/controllers/links/linkId/updateLinkById";
export default async function links(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user?.id) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "PUT") {
const updated = await updateLinkById(
session.user.id,
Number(req.query.id),
req.body
);
return res.status(updated.status).json({
response: updated.response,
});
} else if (req.method === "DELETE") {
const deleted = await deleteLinkById(session.user.id, Number(req.query.id));
return res.status(deleted.status).json({
response: deleted.response,
});
}
}

View File

@ -0,0 +1,63 @@
import type { NextApiRequest, NextApiResponse } from "next";
import archive from "@/lib/api/archive";
import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
export default async function links(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
if (!user) return;
const link = await prisma.link.findUnique({
where: {
id: Number(req.query.id),
},
include: { collection: true },
});
if (!link)
return res.status(404).json({
response: "Link not found.",
});
if (link.collection.ownerId !== user.id)
return res.status(401).json({
response: "Permission denied.",
});
if (req.method === "PUT") {
if (
link?.lastPreserved &&
getTimezoneDifferenceInMinutes(new Date(), link?.lastPreserved) <
RE_ARCHIVE_LIMIT
)
return res.status(400).json({
response: `This link is currently being saved or has already been preserved. Please retry in ${
RE_ARCHIVE_LIMIT -
Math.floor(
getTimezoneDifferenceInMinutes(new Date(), link?.lastPreserved)
)
} minutes or create a new one.`,
});
archive(link.id, link.url, user.id);
return res.status(200).json({
response: "Link is being archived.",
});
}
// TODO - Later?
// else if (req.method === "DELETE") {}
}
const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
const date1 = new Date(future);
const date2 = new Date(past);
const diffInMilliseconds = Math.abs(date1.getTime() - date2.getTime());
const diffInMinutes = diffInMilliseconds / (1000 * 60);
return diffInMinutes;
};

View File

@ -0,0 +1,31 @@
import type { NextApiRequest, NextApiResponse } from "next";
import deleteLinkById from "@/lib/api/controllers/links/linkId/deleteLinkById";
import updateLinkById from "@/lib/api/controllers/links/linkId/updateLinkById";
import getLinkById from "@/lib/api/controllers/links/linkId/getLinkById";
import verifyUser from "@/lib/api/verifyUser";
export default async function links(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
if (!user) return;
if (req.method === "GET") {
const updated = await getLinkById(user.id, Number(req.query.id));
return res.status(updated.status).json({
response: updated.response,
});
} else if (req.method === "PUT") {
const updated = await updateLinkById(
user.id,
Number(req.query.id),
req.body
);
return res.status(updated.status).json({
response: updated.response,
});
} else if (req.method === "DELETE") {
const deleted = await deleteLinkById(user.id, Number(req.query.id));
return res.status(deleted.status).json({
response: deleted.response,
});
}
}

View File

@ -1,20 +1,12 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import getLinks from "@/lib/api/controllers/links/getLinks"; import getLinks from "@/lib/api/controllers/links/getLinks";
import postLink from "@/lib/api/controllers/links/postLink"; import postLink from "@/lib/api/controllers/links/postLink";
import { LinkRequestQuery } from "@/types/global"; import { LinkRequestQuery } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser";
export default async function links(req: NextApiRequest, res: NextApiResponse) { export default async function links(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const user = await verifyUser({ req, res });
if (!user) return;
if (!session?.user?.id) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "GET") { if (req.method === "GET") {
// Convert the type of the request query to "LinkRequestQuery" // Convert the type of the request query to "LinkRequestQuery"
@ -35,13 +27,15 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
searchByUrl: req.query.searchByUrl === "true" ? true : undefined, searchByUrl: req.query.searchByUrl === "true" ? true : undefined,
searchByDescription: searchByDescription:
req.query.searchByDescription === "true" ? true : undefined, req.query.searchByDescription === "true" ? true : undefined,
searchByTextContent:
req.query.searchByTextContent === "true" ? true : undefined,
searchByTags: req.query.searchByTags === "true" ? true : undefined, searchByTags: req.query.searchByTags === "true" ? true : undefined,
}; };
const links = await getLinks(session.user.id, convertedData); const links = await getLinks(user.id, convertedData);
return res.status(links.status).json({ response: links.response }); return res.status(links.status).json({ response: links.response });
} else if (req.method === "POST") { } else if (req.method === "POST") {
const newlink = await postLink(req.body, session.user.id); const newlink = await postLink(req.body, user.id);
return res.status(newlink.status).json({ return res.status(newlink.status).json({
response: newlink.response, response: newlink.response,
}); });

View File

@ -1,32 +1,24 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import exportData from "@/lib/api/controllers/migration/exportData"; import exportData from "@/lib/api/controllers/migration/exportData";
import importFromHTMLFile from "@/lib/api/controllers/migration/importFromHTMLFile"; import importFromHTMLFile from "@/lib/api/controllers/migration/importFromHTMLFile";
import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden"; import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden";
import { MigrationFormat, MigrationRequest } from "@/types/global"; import { MigrationFormat, MigrationRequest } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser";
export const config = { export const config = {
api: { api: {
bodyParser: { bodyParser: {
sizeLimit: process.env.IMPORT_SIZE_LIMIT || "2mb", sizeLimit: `${process.env.IMPORT_SIZE_LIMIT || "5"}mb`,
}, },
}, },
}; };
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const user = await verifyUser({ req, res });
if (!user) return;
if (!session?.user.id) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "GET") { if (req.method === "GET") {
const data = await exportData(session.user.id); const data = await exportData(user.id);
if (data.status === 200) if (data.status === 200)
return res return res
@ -39,10 +31,10 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
let data; let data;
if (request.format === MigrationFormat.htmlFile) if (request.format === MigrationFormat.htmlFile)
data = await importFromHTMLFile(session.user.id, request.data); data = await importFromHTMLFile(user.id, request.data);
if (request.format === MigrationFormat.linkwarden) if (request.format === MigrationFormat.linkwarden)
data = await importFromLinkwarden(session.user.id, request.data); data = await importFromLinkwarden(user.id, request.data);
if (data) return res.status(data.status).json({ response: data.response }); if (data) return res.status(data.status).json({ response: data.response });
} }

View File

@ -1,32 +1,39 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import paymentCheckout from "@/lib/api/paymentCheckout"; import paymentCheckout from "@/lib/api/paymentCheckout";
import { Plan } from "@/types/global"; import { Plan } from "@/types/global";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db";
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID; const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID;
const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID; const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID;
const session = await getServerSession(req, res, authOptions);
if (!session?.user?.id) const token = await getToken({ req });
return res.status(401).json({ response: "You must be logged in." });
else if (!STRIPE_SECRET_KEY || !MONTHLY_PRICE_ID || !YEARLY_PRICE_ID) { if (!STRIPE_SECRET_KEY || !MONTHLY_PRICE_ID || !YEARLY_PRICE_ID)
return res.status(400).json({ response: "Payment is disabled." }); return res.status(400).json({ response: "Payment is disabled." });
}
console.log(token);
if (!token?.id) return res.status(404).json({ response: "Token invalid." });
const email = (await prisma.user.findUnique({ where: { id: token.id } }))
?.email;
if (!email) return res.status(404).json({ response: "User not found." });
let PRICE_ID = MONTHLY_PRICE_ID; let PRICE_ID = MONTHLY_PRICE_ID;
if ((Number(req.query.plan) as unknown as Plan) === Plan.monthly) if ((Number(req.query.plan) as Plan) === Plan.monthly)
PRICE_ID = MONTHLY_PRICE_ID; PRICE_ID = MONTHLY_PRICE_ID;
else if ((Number(req.query.plan) as unknown as Plan) === Plan.yearly) else if ((Number(req.query.plan) as Plan) === Plan.yearly)
PRICE_ID = YEARLY_PRICE_ID; PRICE_ID = YEARLY_PRICE_ID;
if (req.method === "GET") { if (req.method === "GET") {
const users = await paymentCheckout( const users = await paymentCheckout(
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
session?.user.email, email as string,
PRICE_ID PRICE_ID
); );
return res.status(users.status).json({ response: users.response }); return res.status(users.status).json({ response: users.response });

View File

@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from "next";
import getPublicUserById from "@/lib/api/controllers/public/users/getPublicUserById";
import { getToken } from "next-auth/jwt";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await getToken({ req });
const requestingId = token?.id;
const lookupId = req.query.id as string;
// Check if "lookupId" is the user "id" or their "username"
const isId = lookupId.split("").every((e) => Number.isInteger(parseInt(e)));
if (req.method === "GET") {
const users = await getPublicUserById(lookupId, isId, requestingId);
return res.status(users.status).json({ response: users.response });
}
}

View File

@ -1,23 +1,19 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next"; import updeteTagById from "@/lib/api/controllers/tags/tagId/updeteTagById";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]"; import verifyUser from "@/lib/api/verifyUser";
import updateTag from "@/lib/api/controllers/tags/tagId/updeteTagById"; import deleteTagById from "@/lib/api/controllers/tags/tagId/deleteTagById";
export default async function tags(req: NextApiRequest, res: NextApiResponse) { export default async function tags(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const user = await verifyUser({ req, res });
if (!user) return;
if (!session?.user?.username) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
const tagId = Number(req.query.id); const tagId = Number(req.query.id);
if (req.method === "PUT") { if (req.method === "PUT") {
const tags = await updateTag(session.user.id, tagId, req.body); const tags = await updeteTagById(user.id, tagId, req.body);
return res.status(tags.status).json({ response: tags.response });
} else if (req.method === "DELETE") {
const tags = await deleteTagById(user.id, tagId);
return res.status(tags.status).json({ response: tags.response }); return res.status(tags.status).json({ response: tags.response });
} }
} }

View File

@ -1,21 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import getTags from "@/lib/api/controllers/tags/getTags"; import getTags from "@/lib/api/controllers/tags/getTags";
import verifyUser from "@/lib/api/verifyUser";
export default async function tags(req: NextApiRequest, res: NextApiResponse) { export default async function tags(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const user = await verifyUser({ req, res });
if (!user) return;
if (!session?.user?.username) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "GET") { if (req.method === "GET") {
const tags = await getTags(session.user.id); const tags = await getTags(user.id);
return res.status(tags.status).json({ response: tags.response }); return res.status(tags.status).json({ response: tags.response });
} }
} }

View File

@ -1,48 +1,58 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import getUserById from "@/lib/api/controllers/users/userId/getUserById"; import getUserById from "@/lib/api/controllers/users/userId/getUserById";
import getPublicUserById from "@/lib/api/controllers/users/userId/getPublicUserById";
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById"; import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById"; import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db";
import verifySubscription from "@/lib/api/verifySubscription";
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const token = await getToken({ req });
const userId = session?.user.id; const userId = token?.id;
const username = session?.user.username;
const lookupId = req.query.id as string;
const isSelf =
userId === Number(lookupId) || username === lookupId ? true : false;
// Check if "lookupId" is the user "id" or their "username"
const isId = lookupId.split("").every((e) => Number.isInteger(parseInt(e)));
if (req.method === "GET" && !isSelf) {
const users = await getPublicUserById(lookupId, isId, username);
return res.status(users.status).json({ response: users.response });
}
if (!userId) { if (!userId) {
return res.status(401).json({ response: "You must be logged in." }); return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false) }
res.status(401).json({
response: if (userId !== Number(req.query.id))
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.", return res.status(401).json({ response: "Permission denied." });
});
if (req.method === "GET") { if (req.method === "GET") {
const users = await getUserById(session.user.id); const users = await getUserById(userId);
return res.status(users.status).json({ response: users.response }); return res.status(users.status).json({ response: users.response });
} else if (req.method === "PUT") { }
const updated = await updateUserById(session.user, req.body);
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
include: {
subscriptions: true,
},
});
if (user) {
const subscribedUser = await verifySubscription(user);
if (!subscribedUser) {
return res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app if you think this is an issue.",
});
}
} else {
return res.status(404).json({ response: "User not found." });
}
}
if (req.method === "PUT") {
const updated = await updateUserById(userId, req.body);
return res.status(updated.status).json({ response: updated.response }); return res.status(updated.status).json({ response: updated.response });
} else if ( } else if (req.method === "DELETE") {
req.method === "DELETE" &&
session.user.id === Number(req.query.id)
) {
console.log(req.body); console.log(req.body);
const updated = await deleteUserById(session.user.id, req.body); const updated = await deleteUserById(userId, req.body);
return res.status(updated.status).json({ response: updated.response }); return res.status(updated.status).json({ response: updated.response });
} }
} }

View File

@ -12,7 +12,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import { useSession } from "next-auth/react";
import ProfilePhoto from "@/components/ProfilePhoto"; import ProfilePhoto from "@/components/ProfilePhoto";
import SortDropdown from "@/components/SortDropdown"; import SortDropdown from "@/components/SortDropdown";
import useModalStore from "@/store/modals"; import useModalStore from "@/store/modals";
@ -31,8 +30,6 @@ export default function Index() {
const { links } = useLinkStore(); const { links } = useLinkStore();
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { data } = useSession();
const [expandDropdown, setExpandDropdown] = useState(false); const [expandDropdown, setExpandDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = useState(false); const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
@ -82,7 +79,7 @@ export default function Index() {
{activeCollection ? ( {activeCollection ? (
<div <div
className={`min-w-[15rem] ${ className={`min-w-[15rem] ${
activeCollection.members[0] && "mr-3" activeCollection.members[1] && "mr-3"
}`} }`}
> >
<div <div
@ -104,8 +101,10 @@ export default function Index() {
return ( return (
<ProfilePhoto <ProfilePhoto
key={i} key={i}
src={`/api/v1/avatar/${e.userId}?${Date.now()}`} src={e.user.image ? e.user.image : undefined}
className="-mr-3 border-[3px]" className={`${
activeCollection.members[1] && "-mr-3"
} border-[3px]`}
/> />
); );
}) })
@ -220,7 +219,7 @@ export default function Index() {
if (target.id !== "expand-dropdown") if (target.id !== "expand-dropdown")
setExpandDropdown(false); setExpandDropdown(false);
}} }}
className="absolute top-8 right-0 z-10 w-40" className="absolute top-8 right-0 z-10 w-44"
/> />
) : null} ) : null}
</div> </div>
@ -229,9 +228,11 @@ export default function Index() {
</div> </div>
{links.some((e) => e.collectionId === Number(router.query.id)) ? ( {links.some((e) => e.collectionId === Number(router.query.id)) ? (
<div className="grid grid-cols-1 2xl:grid-cols-3 xl:grid-cols-2 gap-5"> <div className="grid grid-cols-1 2xl:grid-cols-3 xl:grid-cols-2 gap-5">
{links.map((e, i) => { {links
return <LinkCard key={i} link={e} count={i} />; .filter((e) => e.collection.id === activeCollection?.id)
})} .map((e, i) => {
return <LinkCard key={i} link={e} count={i} />;
})}
</div> </div>
) : ( ) : (
<NoLinksFound /> <NoLinksFound />

View File

@ -15,11 +15,8 @@ import useModalStore from "@/store/modals";
import SortDropdown from "@/components/SortDropdown"; import SortDropdown from "@/components/SortDropdown";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import useSort from "@/hooks/useSort"; import useSort from "@/hooks/useSort";
import { useTheme } from "next-themes";
export default function Collections() { export default function Collections() {
const { theme } = useTheme();
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const [expandDropdown, setExpandDropdown] = useState(false); const [expandDropdown, setExpandDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = useState(false); const [sortDropdown, setSortDropdown] = useState(false);
@ -154,7 +151,7 @@ export default function Collections() {
</p> </p>
<p className="capitalize text-black dark:text-white"> <p className="capitalize text-black dark:text-white">
Shared collections you're a member of Shared collections you&apos;re a member of
</p> </p>
</div> </div>
</div> </div>

View File

@ -261,7 +261,7 @@ export default function Dashboard() {
title="JSON File" title="JSON File"
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer" className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer"
> >
Linkwarden... Linkwarden File...
<input <input
type="file" type="file"
name="photo" name="photo"

View File

@ -1,10 +1,3 @@
import { useRouter } from "next/router";
import { useEffect } from "react";
export default function Index() { export default function Index() {
const router = useRouter(); return null;
useEffect(() => {
router.push("/dashboard");
}, []);
} }

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

@ -0,0 +1,300 @@
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";
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));
}
};
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), 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-3 mb-6 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

@ -32,7 +32,7 @@ export default function Links() {
</p> </p>
<p className="capitalize text-black dark:text-white"> <p className="capitalize text-black dark:text-white">
All Links from every Collections Links from every Collections
</p> </p>
</div> </div>
</div> </div>

View File

@ -96,7 +96,7 @@ export default function Register() {
return ( return (
<CenteredForm <CenteredForm
text={ text={
process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE process.env.NEXT_PUBLIC_STRIPE
? `Unlock ${ ? `Unlock ${
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14 process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
} days of Premium Service at no cost!` } days of Premium Service at no cost!`
@ -196,7 +196,7 @@ export default function Register() {
/> />
</div> </div>
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? ( {process.env.NEXT_PUBLIC_STRIPE ? (
<div> <div>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
By signing up, you agree to our{" "} By signing up, you agree to our{" "}

View File

@ -19,6 +19,7 @@ export default function Search() {
name: true, name: true,
url: true, url: true,
description: true, description: true,
textContent: true,
tags: true, tags: true,
}); });
@ -32,6 +33,7 @@ export default function Search() {
searchByName: searchFilter.name, searchByName: searchFilter.name,
searchByUrl: searchFilter.url, searchByUrl: searchFilter.url,
searchByDescription: searchFilter.description, searchByDescription: searchFilter.description,
searchByTextContent: searchFilter.textContent,
searchByTags: searchFilter.tags, searchByTags: searchFilter.tags,
}); });

View File

@ -21,13 +21,8 @@ export default function Account() {
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
const [profileStatus, setProfileStatus] = useState(true);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const handleProfileStatus = (e: boolean) => {
setProfileStatus(!e);
};
const { account, updateAccount } = useAccountStore(); const { account, updateAccount } = useAccountStore();
const [user, setUser] = useState<AccountSettings>( const [user, setUser] = useState<AccountSettings>(
@ -40,12 +35,11 @@ export default function Account() {
username: "", username: "",
email: "", email: "",
emailVerified: null, emailVerified: null,
image: null, image: "",
isPrivate: true, isPrivate: true,
// @ts-ignore // @ts-ignore
createdAt: null, createdAt: null,
whitelistedUsers: [], whitelistedUsers: [],
profilePic: "",
} as unknown as AccountSettings) } as unknown as AccountSettings)
); );
@ -68,7 +62,7 @@ export default function Account() {
) { ) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
setUser({ ...user, profilePic: reader.result as string }); setUser({ ...user, image: reader.result as string });
}; };
reader.readAsDataURL(resizedFile); reader.readAsDataURL(resizedFile);
} else { } else {
@ -92,20 +86,6 @@ export default function Account() {
if (response.ok) { if (response.ok) {
toast.success("Settings Applied!"); toast.success("Settings Applied!");
if (user.email !== account.email) {
update({
id: data?.user.id,
});
signOut();
} else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
} else toast.error(response.data as string); } else toast.error(response.data as string);
setSubmitLoader(false); setSubmitLoader(false);
}; };
@ -178,18 +158,14 @@ export default function Account() {
<div className="grid sm:grid-cols-2 gap-3 auto-rows-auto"> <div className="grid sm:grid-cols-2 gap-3 auto-rows-auto">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div> <div>
<p className="text-sm text-black dark:text-white mb-2"> <p className="text-black dark:text-white mb-2">Display Name</p>
Display Name
</p>
<TextInput <TextInput
value={user.name || ""} value={user.name || ""}
onChange={(e) => setUser({ ...user, name: e.target.value })} onChange={(e) => setUser({ ...user, name: e.target.value })}
/> />
</div> </div>
<div> <div>
<p className="text-sm text-black dark:text-white mb-2"> <p className="text-black dark:text-white mb-2">Username</p>
Username
</p>
<TextInput <TextInput
value={user.username || ""} value={user.username || ""}
onChange={(e) => setUser({ ...user, username: e.target.value })} onChange={(e) => setUser({ ...user, username: e.target.value })}
@ -198,38 +174,37 @@ export default function Account() {
{emailEnabled ? ( {emailEnabled ? (
<div> <div>
<p className="text-sm text-black dark:text-white mb-2">Email</p> <p className="text-black dark:text-white mb-2">Email</p>
{user.email !== account.email &&
process.env.NEXT_PUBLIC_STRIPE === "true" ? (
<p className="text-gray-500 dark:text-gray-400 mb-2 text-sm">
Updating this field will change your billing email as well
</p>
) : undefined}
<TextInput <TextInput
value={user.email || ""} value={user.email || ""}
onChange={(e) => setUser({ ...user, email: e.target.value })} onChange={(e) => setUser({ ...user, email: e.target.value })}
/> />
</div> </div>
) : undefined} ) : undefined}
{user.email !== account.email ? (
<p className="text-gray-500 dark:text-gray-400">
You will need to log back in after you apply this Email.
</p>
) : undefined}
</div> </div>
<div className="sm:row-span-2 sm:justify-self-center mx-auto my-3"> <div className="sm:row-span-2 sm:justify-self-center mx-auto my-3">
<p className="text-sm text-black dark:text-white mb-2 text-center"> <p className="text-black dark:text-white mb-2 text-center">
Profile Photo Profile Photo
</p> </p>
<div className="w-28 h-28 flex items-center justify-center rounded-full relative"> <div className="w-28 h-28 flex items-center justify-center rounded-full relative">
<ProfilePhoto <ProfilePhoto
priority={true} priority={true}
src={user.profilePic} src={user.image ? user.image : undefined}
className="h-auto border-none w-28" className="h-auto border-none w-28"
status={handleProfileStatus}
/> />
{profileStatus && ( {user.image && (
<div <div
onClick={() => onClick={() =>
setUser({ setUser({
...user, ...user,
profilePic: "", image: "",
}) })
} }
className="absolute top-1 left-1 w-5 h-5 flex items-center justify-center border p-1 border-slate-200 dark:border-neutral-700 rounded-full bg-white dark:bg-neutral-800 text-center select-none cursor-pointer duration-100 hover:text-red-500" className="absolute top-1 left-1 w-5 h-5 flex items-center justify-center border p-1 border-slate-200 dark:border-neutral-700 rounded-full bg-white dark:bg-neutral-800 text-center select-none cursor-pointer duration-100 hover:text-red-500"
@ -265,7 +240,7 @@ export default function Account() {
<div className="flex gap-3 flex-col"> <div className="flex gap-3 flex-col">
<div> <div>
<p className="text-sm text-black dark:text-white mb-2"> <p className="text-black dark:text-white mb-2">
Import your data from other platforms. Import your data from other platforms.
</p> </p>
<div <div
@ -294,7 +269,7 @@ export default function Account() {
title="JSON File" title="JSON File"
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer" className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer"
> >
Linkwarden... Linkwarden File...
<input <input
type="file" type="file"
name="photo" name="photo"
@ -330,7 +305,7 @@ export default function Account() {
</div> </div>
<div> <div>
<p className="text-sm text-black dark:text-white mb-2"> <p className="text-black dark:text-white mb-2">
Download your data instantly. Download your data instantly.
</p> </p>
<Link className="w-fit" href="/api/v1/migration"> <Link className="w-fit" href="/api/v1/migration">
@ -354,7 +329,6 @@ export default function Account() {
<Checkbox <Checkbox
label="Make profile private" label="Make profile private"
state={user.isPrivate} state={user.isPrivate}
className="text-sm sm:text-base"
onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })} onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })}
/> />
@ -364,7 +338,7 @@ export default function Account() {
{user.isPrivate && ( {user.isPrivate && (
<div> <div>
<p className="text-sm text-black dark:text-white mt-2"> <p className="text-black dark:text-white mt-2">
Whitelisted Users Whitelisted Users
</p> </p>
<p className="text-gray-500 dark:text-gray-300 text-sm mb-3"> <p className="text-gray-500 dark:text-gray-300 text-sm mb-3">
@ -400,7 +374,7 @@ export default function Account() {
<p> <p>
This will permanently delete ALL the Links, Collections, Tags, and This will permanently delete ALL the Links, Collections, Tags, and
archived data you own.{" "} archived data you own.{" "}
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE {process.env.NEXT_PUBLIC_STRIPE
? "It will also cancel your subscription. " ? "It will also cancel your subscription. "
: undefined}{" "} : undefined}{" "}
You will be prompted to enter your password before the deletion You will be prompted to enter your password before the deletion

View File

@ -8,7 +8,7 @@ export default function Appearance() {
return ( return (
<SettingsLayout> <SettingsLayout>
<p className="mb-3 text-sm">Select Theme</p> <p className="mb-3">Select Theme</p>
<div className="flex gap-3 w-full"> <div className="flex gap-3 w-full">
<div <div
className={`w-full text-center outline-solid outline-sky-100 outline dark:outline-neutral-700 h-40 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-black ${ className={`w-full text-center outline-solid outline-sky-100 outline dark:outline-neutral-700 h-40 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-black ${

View File

@ -6,8 +6,7 @@ export default function Billing() {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE) if (!process.env.NEXT_PUBLIC_STRIPE) router.push("/settings/profile");
router.push("/settings/profile");
}, []); }, []);
return ( return (

View File

@ -72,14 +72,14 @@ export default function Password() {
<p> <p>
This will permanently delete all the Links, Collections, Tags, and This will permanently delete all the Links, Collections, Tags, and
archived data you own. It will also log you out archived data you own. It will also log you out
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE {process.env.NEXT_PUBLIC_STRIPE
? " and cancel your subscription" ? " and cancel your subscription"
: undefined} : undefined}
. This action is irreversible! . This action is irreversible!
</p> </p>
<div> <div>
<p className="text-sm mb-2 text-black dark:text-white"> <p className="mb-2 text-black dark:text-white">
Confirm Your Password Confirm Your Password
</p> </p>
@ -91,7 +91,7 @@ export default function Password() {
/> />
</div> </div>
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? ( {process.env.NEXT_PUBLIC_STRIPE ? (
<fieldset className="border rounded-md p-2 border-sky-500"> <fieldset className="border rounded-md p-2 border-sky-500">
<legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-sky-500"> <legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-sky-500">
<b>Optional</b>{" "} <b>Optional</b>{" "}
@ -119,7 +119,8 @@ export default function Password() {
</label> </label>
<div> <div>
<p className="text-sm mb-2 text-black dark:text-white"> <p className="text-sm mb-2 text-black dark:text-white">
More information (the more details, the more helpful it'd be) More information (the more details, the more helpful it&apos;d
be)
</p> </p>
<textarea <textarea

View File

@ -1,7 +1,6 @@
import SettingsLayout from "@/layouts/SettingsLayout"; import SettingsLayout from "@/layouts/SettingsLayout";
import { useState } from "react"; import { useState } from "react";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "@/components/SubmitButton"; import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
@ -51,7 +50,7 @@ export default function Password() {
should be at least 8 characters. should be at least 8 characters.
</p> </p>
<div className="w-full flex flex-col gap-2 justify-between"> <div className="w-full flex flex-col gap-2 justify-between">
<p className="text-sm text-black dark:text-white">New Password</p> <p className="text-black dark:text-white">New Password</p>
<TextInput <TextInput
value={newPassword} value={newPassword}
@ -60,9 +59,7 @@ export default function Password() {
type="password" type="password"
/> />
<p className="text-sm text-black dark:text-white"> <p className="text-black dark:text-white">Confirm New Password</p>
Confirm New Password
</p>
<TextInput <TextInput
value={newPassword2} value={newPassword2}

View File

@ -1,18 +1,16 @@
import SubmitButton from "@/components/SubmitButton"; import { signOut, useSession } from "next-auth/react";
import { signOut } from "next-auth/react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import CenteredForm from "@/layouts/CenteredForm"; import CenteredForm from "@/layouts/CenteredForm";
import { Plan } from "@/types/global"; import { Plan } from "@/types/global";
export default function Subscribe() { export default function Subscribe() {
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const session = useSession();
const [plan, setPlan] = useState<Plan>(1); const [plan, setPlan] = useState<Plan>(1);
const { data, status } = useSession();
const router = useRouter(); const router = useRouter();
async function submit() { async function submit() {
@ -86,7 +84,7 @@ export default function Subscribe() {
<p className="font-semibold"> <p className="font-semibold">
Billed {plan === Plan.monthly ? "Monthly" : "Yearly"} Billed {plan === Plan.monthly ? "Monthly" : "Yearly"}
</p> </p>
<fieldset className="w-full px-4 pb-4 pt-1 rounded-md border border-sky-100 dark:border-neutral-700"> <fieldset className="w-full flex-col flex justify-evenly px-4 pb-4 pt-1 rounded-md border border-sky-100 dark:border-neutral-700">
<legend className="w-fit font-extralight px-2 border border-sky-100 dark:border-neutral-700 rounded-md text-xl"> <legend className="w-fit font-extralight px-2 border border-sky-100 dark:border-neutral-700 rounded-md text-xl">
Total Total
</legend> </legend>

View File

@ -23,7 +23,7 @@ export default function Index() {
const router = useRouter(); const router = useRouter();
const { links } = useLinkStore(); const { links } = useLinkStore();
const { tags, updateTag } = useTagStore(); const { tags, updateTag, removeTag } = useTagStore();
const [sortDropdown, setSortDropdown] = useState(false); const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
@ -81,6 +81,25 @@ export default function Index() {
setRenameTag(false); setRenameTag(false);
}; };
const remove = async () => {
setSubmitLoader(true);
const load = toast.loading("Applying...");
let response;
if (activeTag?.id) response = await removeTag(activeTag?.id);
toast.dismiss(load);
if (response?.ok) {
toast.success("Tag Removed.");
router.push("/links");
} else toast.error(response?.data as string);
setSubmitLoader(false);
setRenameTag(false);
};
return ( return (
<MainLayout> <MainLayout>
<div className="p-5 flex flex-col gap-5 w-full"> <div className="p-5 flex flex-col gap-5 w-full">
@ -153,6 +172,13 @@ export default function Index() {
setExpandDropdown(false); setExpandDropdown(false);
}, },
}, },
{
name: "Remove Tag",
onClick: () => {
remove();
setExpandDropdown(false);
},
},
]} ]}
onClickOutside={(e: Event) => { onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
@ -191,9 +217,11 @@ export default function Index() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 2xl:grid-cols-3 xl:grid-cols-2 gap-5"> <div className="grid grid-cols-1 2xl:grid-cols-3 xl:grid-cols-2 gap-5">
{links.map((e, i) => { {links
return <LinkCard key={i} link={e} count={i} />; .filter((e) => e.tags.some((e) => e.id === Number(router.query.id)))
})} .map((e, i) => {
return <LinkCard key={i} link={e} count={i} />;
})}
</div> </div>
</div> </div>
</MainLayout> </MainLayout>

View File

@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `image` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Link" ADD COLUMN "pdfPath" TEXT,
ADD COLUMN "screenshotPath" TEXT;
-- AlterTable
ALTER TABLE "User" DROP COLUMN "image",
ADD COLUMN "imagePath" TEXT;

View File

@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "Collection" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "Link" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "UsersAndCollections" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "WhitelistedUser" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@ -0,0 +1,18 @@
/*
Warnings:
- You are about to drop the `Account` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Account" DROP CONSTRAINT "Account_userId_fkey";
-- DropForeignKey
ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey";
-- DropTable
DROP TABLE "Account";
-- DropTable
DROP TABLE "Session";

View File

@ -0,0 +1 @@
ALTER TABLE "User" RENAME COLUMN "imagePath" TO "image";

Some files were not shown because too many files have changed in this diff Show More