Merge branch 'dev' into collection-sharing-layout

This commit is contained in:
Daniel 2023-12-20 01:52:51 +03:30 committed by GitHub
commit 31cf3c4f01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 591 additions and 1752 deletions

View File

@ -16,6 +16,7 @@ NEXT_PUBLIC_CREDENTIALS_ENABLED=
DISABLE_NEW_SSO_USERS= DISABLE_NEW_SSO_USERS=
RE_ARCHIVE_LIMIT= RE_ARCHIVE_LIMIT=
NEXT_PUBLIC_MAX_FILE_SIZE= NEXT_PUBLIC_MAX_FILE_SIZE=
MAX_LINKS_PER_USER=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=

View File

@ -35,6 +35,8 @@ export default function CollectionCard({ collection, className }: Props) {
name: "", name: "",
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
}); });
useEffect(() => { useEffect(() => {
@ -48,6 +50,8 @@ export default function CollectionCard({ collection, className }: Props) {
name: account.name, name: account.name,
username: account.username as string, username: account.username as string,
image: account.image as string, image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean,
}); });
} }
}; };
@ -70,8 +74,7 @@ export default function CollectionCard({ collection, className }: Props) {
> >
<i className="bi-three-dots text-xl" title="More"></i> <i className="bi-three-dots text-xl" title="More"></i>
</div> </div>
<ul <ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
{permissions === true ? ( {permissions === true ? (
<li> <li>
<div <div
@ -166,17 +169,23 @@ export default function CollectionCard({ collection, className }: Props) {
<div className="text-right"> <div className="text-right">
<div className="font-bold text-sm flex justify-end gap-1 items-center"> <div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? ( {collection.isPublic ? (
<i className="bi-globe-americas drop-shadow text-neutral" <i
title="This collection is being shared publicly."></i> className="bi-globe-americas drop-shadow text-neutral"
title="This collection is being shared publicly."
></i>
) : undefined} ) : undefined}
<i className="bi-link-45deg text-lg text-neutral" <i
title="This collection is being shared publicly."></i> className="bi-link-45deg text-lg text-neutral"
title="This collection is being shared publicly."
></i>
{collection._count && collection._count.links} {collection._count && collection._count.links}
</div> </div>
<div className="flex items-center justify-end gap-1 text-neutral"> <div className="flex items-center justify-end gap-1 text-neutral">
<p className="font-bold text-xs flex gap-1 items-center"> <p className="font-bold text-xs flex gap-1 items-center">
<i className="bi-calendar3 text-neutral" <i
title="This collection is being shared publicly."></i> className="bi-calendar3 text-neutral"
title="This collection is being shared publicly."
></i>
{formattedDate} {formattedDate}
</p> </p>
</div> </div>

View File

@ -9,7 +9,7 @@ export default function dashboardItem({
}) { }) {
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<div className="w-20 aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none"> <div className="w-[4.7rem] aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
<i className={`${icon} text-primary text-4xl drop-shadow`}></i> <i className={`${icon} text-primary text-4xl drop-shadow`}></i>
</div> </div>
<div className="ml-4 flex flex-col justify-center"> <div className="ml-4 flex flex-col justify-center">

View File

@ -15,7 +15,7 @@ type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
position?: string; position?: string;
} };
export default function LinkActions({ link, collection, position }: Props) { export default function LinkActions({ link, collection, position }: Props) {
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
@ -23,7 +23,6 @@ export default function LinkActions({ link, collection, position }: Props) {
const [editLinkModal, setEditLinkModal] = useState(false); const [editLinkModal, setEditLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false); const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false); const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const [expandedLink, setExpandedLink] = useState(false);
const { account } = useAccountStore(); const { account } = useAccountStore();
@ -57,9 +56,6 @@ export default function LinkActions({ link, collection, position }: Props) {
return ( return (
<div> <div>
{permissions === true ||
permissions?.canUpdate ||
permissions?.canDelete ? (
<div <div
className={`dropdown dropdown-left absolute ${ className={`dropdown dropdown-left absolute ${
position || "top-3 right-3" position || "top-3 right-3"
@ -70,10 +66,13 @@ export default function LinkActions({ link, collection, position }: Props) {
role="button" role="button"
className="btn btn-ghost btn-sm btn-square text-neutral" className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<i id={"expand-dropdown" + collection.id} title="More" className="bi-three-dots text-xl"/> <i
id={"expand-dropdown" + collection.id}
title="More"
className="bi-three-dots text-xl"
/>
</div> </div>
<ul <ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
{permissions === true ? ( {permissions === true ? (
<li> <li>
<div <div
@ -104,7 +103,6 @@ export default function LinkActions({ link, collection, position }: Props) {
</div> </div>
</li> </li>
) : undefined} ) : undefined}
{permissions === true ? (
<li> <li>
<div <div
role="button" role="button"
@ -118,7 +116,6 @@ export default function LinkActions({ link, collection, position }: Props) {
Preserved Formats Preserved Formats
</div> </div>
</li> </li>
) : undefined}
{permissions === true || permissions?.canDelete ? ( {permissions === true || permissions?.canDelete ? (
<li> <li>
<div <div
@ -135,7 +132,6 @@ export default function LinkActions({ link, collection, position }: Props) {
) : undefined} ) : undefined}
</ul> </ul>
</div> </div>
) : undefined}
{editLinkModal ? ( {editLinkModal ? (
<EditLinkModal <EditLinkModal

View File

@ -1,4 +1,4 @@
import React, { MouseEventHandler, ReactNode } from "react"; import React, { MouseEventHandler, ReactNode, useEffect } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler"; import ClickAwayHandler from "@/components/ClickAwayHandler";
type Props = { type Props = {
@ -8,6 +8,13 @@ type Props = {
}; };
export default function Modal({ toggleModal, className, children }: Props) { export default function Modal({ toggleModal, className, children }: Props) {
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
});
return ( return (
<div className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-30"> <div className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-30">
<ClickAwayHandler <ClickAwayHandler
@ -21,9 +28,7 @@ export default function Modal({ toggleModal, className, children }: Props) {
onClick={toggleModal as MouseEventHandler<HTMLDivElement>} onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10" className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
> >
<i <i className="bi-x text-neutral text-2xl"></i>
className="bi-x text-neutral text-2xl"
></i>
</div> </div>
{children} {children}
</div> </div>

View File

@ -1,113 +0,0 @@
import { Dispatch, SetStateAction, useState } from "react";
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import SubmitButton from "@/components/SubmitButton";
import { HexColorPicker } from "react-colorful";
import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput";
type Props = {
toggleCollectionModal: Function;
setCollection: Dispatch<
SetStateAction<CollectionIncludingMembersAndLinkCount>
>;
collection: CollectionIncludingMembersAndLinkCount;
method: "CREATE" | "UPDATE" | "VIEW_TEAM";
};
export default function CollectionInfo({
toggleCollectionModal,
setCollection,
collection,
method,
}: Props) {
const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection, addCollection } = useCollectionStore();
const submit = async () => {
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading(
method === "UPDATE" ? "Applying..." : "Creating..."
);
let response;
if (method === "CREATE") response = await addCollection(collection);
else response = await updateCollection(collection);
toast.dismiss(load);
if (response.ok) {
toast.success(
`Collection ${method === "UPDATE" ? "Saved!" : "Created!"}`
);
toggleCollectionModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">Name</p>
<div className="flex flex-col gap-3">
<TextInput
value={collection.name}
placeholder="e.g. Example Collection"
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div className="color-picker flex justify-between">
<div className="flex flex-col justify-between items-center w-32">
<p className="w-full mb-2">Color</p>
<div style={{ color: collection.color }}>
<i className={"bi-folder-fill"}></i>
</div>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
Reset
</div>
</div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">Description</p>
<textarea
className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-sky-300 dark:focus:border-sky-600"
placeholder="The purpose of this Collection..."
value={collection.description}
onChange={(e) =>
setCollection({
...collection,
description: e.target.value,
})
}
/>
</div>
</div>
<SubmitButton
onClick={submit}
loading={submitLoader}
label={method === "CREATE" ? "Add" : "Save"}
className="mx-auto mt-2"
/>
</div>
);
}

View File

@ -1,114 +0,0 @@
import React, { useState } from "react";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput";
type Props = {
toggleDeleteCollectionModal: Function;
collection: CollectionIncludingMembersAndLinkCount;
};
export default function DeleteCollection({
toggleDeleteCollectionModal,
collection,
}: Props) {
const [inputField, setInputField] = useState("");
const { removeCollection } = useCollectionStore();
const router = useRouter();
const submit = async () => {
if (permissions === true) if (collection.name !== inputField) return null;
const load = toast.loading("Deleting...");
const response = await removeCollection(collection.id as number);
toast.dismiss(load);
if (response.ok) {
toast.success("Collection Deleted.");
toggleDeleteCollectionModal();
router.push("/collections");
}
};
const permissions = usePermissions(collection.id as number);
return (
<div className="flex flex-col gap-3 justify-between sm:w-[35rem] w-80">
{permissions === true ? (
<>
<p className="text-red-500 font-bold text-center">Warning!</p>
<div className="max-h-[20rem] overflow-y-auto">
<div>
<p>
Please note that deleting the collection will permanently remove
all its contents, including the following:
</p>
<div className="p-3">
<li className="list-inside">
Links: All links within the collection will be permanently
deleted.
</li>
<li className="list-inside">
Tags: All tags associated with the collection will be removed.
</li>
<li className="list-inside">
Screenshots/PDFs: Any screenshots or PDFs attached to links
within this collection will be permanently deleted.
</li>
<li className="list-inside">
Members: Any members who have been granted access to the
collection will lose their permissions and no longer be able
to view or interact with the content.
</li>
</div>
<p>
Please double-check that you have backed up any essential data
and have informed the relevant members about this action.
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<p className="text-center">
To confirm, type &quot;
<span className="font-bold">{collection.name}</span>
&quot; in the box below:
</p>
<TextInput
autoFocus={true}
value={inputField}
onChange={(e) => setInputField(e.target.value)}
placeholder={`Type "${collection.name}" Here.`}
className="w-3/4 mx-auto"
/>
</div>
</>
) : (
<p>Click the button below to leave the current collection.</p>
)}
<div
className={`mx-auto mt-2 text-white flex items-center gap-2 py-2 px-5 rounded-md select-none font-bold duration-100 ${
permissions === true
? inputField === collection.name
? "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
: "cursor-not-allowed bg-red-300 dark:bg-red-900"
: "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
}`}
onClick={submit}
>
<i className={`${ permissions === true ? 'bi-trash' : 'bi-arrow-right'}`}></i>
{permissions === true ? "Delete" : "Leave"} Collection
</div>
</div>
);
}

View File

@ -1,417 +0,0 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Checkbox from "../../Checkbox";
import SubmitButton from "@/components/SubmitButton";
import ProfilePhoto from "@/components/ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast";
import getPublicUserData from "@/lib/client/getPublicUserData";
import TextInput from "@/components/TextInput";
import useAccountStore from "@/store/account";
type Props = {
toggleCollectionModal: Function;
setCollection: Dispatch<
SetStateAction<CollectionIncludingMembersAndLinkCount>
>;
collection: CollectionIncludingMembersAndLinkCount;
method: "CREATE" | "UPDATE";
};
export default function TeamManagement({
toggleCollectionModal,
setCollection,
collection,
method,
}: Props) {
const { account } = useAccountStore();
const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL);
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
const [memberUsername, setMemberUsername] = useState("");
const [collectionOwner, setCollectionOwner] = useState({
id: null,
name: "",
username: "",
image: "",
});
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
fetchOwner();
}, []);
const { addCollection, updateCollection } = useCollectionStore();
const setMemberState = (newMember: Member) => {
if (!collection) return null;
setCollection({
...collection,
members: [...collection.members, newMember],
});
setMemberUsername("");
};
const [submitLoader, setSubmitLoader] = useState(false);
const submit = async () => {
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading(
method === "UPDATE" ? "Applying..." : "Creating..."
);
let response;
if (method === "CREATE") response = await addCollection(collection);
else response = await updateCollection(collection);
toast.dismiss(load);
if (response.ok) {
toast.success("Collection Saved!");
toggleCollectionModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{permissions === true && (
<>
<p>Make Public</p>
<Checkbox
label="Make this a public collection."
state={collection.isPublic}
onClick={() =>
setCollection({ ...collection, isPublic: !collection.isPublic })
}
/>
<p className="text-neutral text-sm">
This will let <b>Anyone</b> to view this collection.
</p>
</>
)}
{collection.isPublic ? (
<div>
<p className="mb-2">Public Link (Click to copy)</p>
<div
onClick={() => {
try {
navigator.clipboard
.writeText(publicCollectionURL)
.then(() => toast.success("Copied!"));
} catch (err) {
console.log(err);
}
}}
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 dark:bg-neutral-950 border-neutral-content border-solid border outline-none hover:border-sky-300 dark:hover:border-sky-600 duration-100 cursor-text"
>
{publicCollectionURL}
</div>
</div>
) : null}
{permissions !== true && collection.isPublic && (
<div className="divider mb-3 mt-0"></div>
)}
{permissions === true && (
<>
<p>Member Management</p>
<div className="flex items-center gap-2">
<TextInput
value={memberUsername || ""}
placeholder="Username (without the '@')"
onChange={(e) => setMemberUsername(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
account.username as string,
memberUsername || "",
collection,
setMemberState
)
}
/>
<div
onClick={() =>
addMemberToCollection(
account.username as string,
memberUsername || "",
collection,
setMemberState
)
}
className="flex items-center justify-center bg-sky-700 hover:bg-sky-600 duration-100 text-white w-10 h-10 p-2 rounded-md cursor-pointer"
>
<i className="bi-person-add text-xl"></i>
</div>
</div>
</>
)}
{collection?.members[0]?.user && (
<>
<p className="text-center text-neutral text-xs sm:text-sm">
(All Members have <b>Read</b> access to this collection.)
</p>
<div className="flex flex-col gap-3 rounded-md">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<div
key={i}
className="relative border p-2 rounded-md border-neutral flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
>
{permissions === true && (
<i
className={"bi-x text-xl absolute right-1 top-1 text-neutral hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"}
title="Remove Member"
onClick={() => {
const updatedMembers = collection.members.filter(
(member) => {
return member.user.username !== e.user.username;
}
);
setCollection({
...collection,
members: updatedMembers,
});
}}
/>
)}
<div className="flex items-center gap-2">
<ProfilePhoto
src={e.user.image ? e.user.image : undefined}
className="border-[3px]"
/>
<div>
<p className="text-sm font-bold">{e.user.name}</p>
<p className="text-neutral">@{e.user.username}</p>
</div>
</div>
<div className="flex sm:block items-center justify-between gap-5 min-w-[10rem]">
<div>
<p
className={`font-bold text-sm ${
permissions === true ? "" : "mb-2"
}`}
>
Permissions
</p>
{permissions === true && (
<p className="text-xs text-neutral mb-2">
(Click to toggle.)
</p>
)}
</div>
{permissions !== true &&
!e.canCreate &&
!e.canUpdate &&
!e.canDelete ? (
<p className="text-sm text-neutral">
Has no permissions.
</p>
) : (
<div>
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canCreate"
className="peer sr-only"
checked={e.canCreate}
onChange={() => {
if (permissions === true) {
const updatedMembers = collection.members.map(
(member) => {
if (
member.user.username === e.user.username
) {
return {
...member,
canCreate: !e.canCreate,
};
}
return member;
}
);
setCollection({
...collection,
members: updatedMembers,
});
}
}}
/>
<span
className={`peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: ""
} rounded p-1 select-none`}
>
Create
</span>
</label>
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canUpdate"
className="peer sr-only"
checked={e.canUpdate}
onChange={() => {
if (permissions === true) {
const updatedMembers = collection.members.map(
(member) => {
if (
member.user.username === e.user.username
) {
return {
...member,
canUpdate: !e.canUpdate,
};
}
return member;
}
);
setCollection({
...collection,
members: updatedMembers,
});
}
}}
/>
<span
className={`peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: ""
} rounded p-1 select-none`}
>
Update
</span>
</label>
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canDelete"
className="peer sr-only"
checked={e.canDelete}
onChange={() => {
if (permissions === true) {
const updatedMembers = collection.members.map(
(member) => {
if (
member.user.username === e.user.username
) {
return {
...member,
canDelete: !e.canDelete,
};
}
return member;
}
);
setCollection({
...collection,
members: updatedMembers,
});
}
}}
/>
<span
className={`peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: ""
} rounded p-1 select-none`}
>
Delete
</span>
</label>
</div>
)}
</div>
</div>
);
})}
</div>
</>
)}
<div
className="relative border px-2 rounded-md border-neutral-content flex min-h-[7rem] sm:min-h-[5rem] gap-2 justify-between"
title={`'@${collectionOwner.username}' is the owner of this collection.`}
>
<div className="flex items-center gap-2">
<ProfilePhoto
src={collectionOwner.image ? collectionOwner.image : undefined}
className="border-[3px]"
/>
<div>
<div className="flex items-center gap-1">
<p className="text-sm font-bold">{collectionOwner.name}</p>
</div>
<p className="text-neutral">@{collectionOwner.username}</p>
</div>
</div>
<div className="flex flex-col justify-center min-w-[10rem]">
<p className={`font-bold text-sm`}>Permissions</p>
<p>Full Access (Owner)</p>
</div>
</div>
{permissions === true && (
<SubmitButton
onClick={submit}
loading={submitLoader}
label={method === "CREATE" ? "Add" : "Save"}
className="mx-auto mt-2"
/>
)}
</div>
);
}

View File

@ -1,83 +0,0 @@
import { useEffect, useState } from "react";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import ProfilePhoto from "@/components/ProfilePhoto";
import getPublicUserData from "@/lib/client/getPublicUserData";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
};
export default function ViewTeam({ collection }: Props) {
const [collectionOwner, setCollectionOwner] = useState({
id: null,
name: "",
username: "",
image: "",
});
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
fetchOwner();
}, []);
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<p className="ml-10 text-xl font-thin">Team</p>
<p>Here are all the members who are collaborating on this collection.</p>
<div
className="relative border px-2 rounded-md border-neutral flex min-h-[4rem] gap-2 justify-between"
title={`@${collectionOwner.username} is the owner of this collection.`}
>
<div className="flex items-center gap-2 w-full">
<ProfilePhoto
src={collectionOwner.image ? collectionOwner.image : undefined}
className="border-[3px]"
/>
<div className="w-full">
<div className="flex items-center gap-1 w-full justify-between">
<p className="text-sm font-bold">{collectionOwner.name}</p>
<div className="flex text-xs gap-1 items-center">
Admin
</div>
</div>
<p className="text-neutral">@{collectionOwner.username}</p>
</div>
</div>
</div>
{collection?.members[0]?.user && (
<>
<div className="flex flex-col gap-3 rounded-md">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<div
key={i}
className="relative border p-2 rounded-md border-neutral flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
>
<div className="flex items-center gap-2">
<ProfilePhoto
src={e.user.image ? e.user.image : undefined}
className="border-[3px]"
/>
<div>
<p className="text-sm font-bold">{e.user.name}</p>
<p className="text-neutral">@{e.user.username}</p>
</div>
</div>
</div>
);
})}
</div>
</>
)}
</div>
);
}

View File

@ -1,141 +0,0 @@
import { Tab } from "@headlessui/react";
import CollectionInfo from "./CollectionInfo";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import TeamManagement from "./TeamManagement";
import { useEffect, useState } from "react";
import DeleteCollection from "./DeleteCollection";
import ViewTeam from "./ViewTeam";
type Props =
| {
toggleCollectionModal: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
method: "UPDATE";
isOwner: boolean;
className?: string;
defaultIndex?: number;
}
| {
toggleCollectionModal: Function;
activeCollection?: CollectionIncludingMembersAndLinkCount;
method: "CREATE";
isOwner: boolean;
className?: string;
defaultIndex?: number;
}
| {
toggleCollectionModal: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
method: "VIEW_TEAM";
isOwner: boolean;
className?: string;
defaultIndex?: number;
};
export default function CollectionModal({
className,
defaultIndex,
toggleCollectionModal,
isOwner,
activeCollection,
method,
}: Props) {
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
activeCollection || {
name: "",
description: "",
color: "#0ea5e9",
isPublic: false,
members: [],
}
);
return (
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && (
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
New Collection
</p>
)}
{method !== "VIEW_TEAM" && (
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5">
{method === "UPDATE" && (
<>
{isOwner && (
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none"
}
>
Collection Info
</Tab>
)}
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none"
}
>
{isOwner ? "Share & Collaborate" : "View Team"}
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none"
}
>
{isOwner ? "Delete Collection" : "Leave Collection"}
</Tab>
</>
)}
</Tab.List>
)}
<Tab.Panels>
{(isOwner || method === "CREATE") && (
<Tab.Panel>
<CollectionInfo
toggleCollectionModal={toggleCollectionModal}
setCollection={setCollection}
collection={collection}
method={method}
/>
</Tab.Panel>
)}
{method === "UPDATE" && (
<>
<Tab.Panel>
<TeamManagement
toggleCollectionModal={toggleCollectionModal}
setCollection={setCollection}
collection={collection}
method={method}
/>
</Tab.Panel>
<Tab.Panel>
<DeleteCollection
toggleDeleteCollectionModal={toggleCollectionModal}
collection={collection}
/>
</Tab.Panel>
</>
)}
{method === "VIEW_TEAM" && (
<>
<Tab.Panel>
<ViewTeam collection={collection} />
</Tab.Panel>
</>
)}
</Tab.Panels>
</Tab.Group>
</div>
);
}

View File

@ -1,270 +0,0 @@
import React, { useEffect, useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import useLinkStore from "@/store/links";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import SubmitButton from "../../SubmitButton";
import { toast } from "react-hot-toast";
import Link from "next/link";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
type Props =
| {
toggleLinkModal: Function;
method: "CREATE";
activeLink?: LinkIncludingShortenedCollectionAndTags;
}
| {
toggleLinkModal: Function;
method: "UPDATE";
activeLink: LinkIncludingShortenedCollectionAndTags;
};
export default function AddOrEditLink({
toggleLinkModal,
method,
activeLink,
}: Props) {
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(
method === "UPDATE" ? true : false
);
const { data } = useSession();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>(
activeLink || {
name: "",
url: "",
type: "",
description: "",
tags: [],
screenshotPath: "",
pdfPath: "",
readabilityPath: "",
textContent: "",
collection: {
name: "",
ownerId: data?.user.id as number,
},
}
);
const { updateLink, addLink } = useLinkStore();
const router = useRouter();
const { collections } = useCollectionStore();
useEffect(() => {
if (method === "CREATE") {
if (router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
router.asPath.startsWith("/collections/")
)
setLink({
...link,
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...link,
collection: {
// id: ,
name: "Unorganized",
ownerId: data?.user.id as number,
},
});
}
}, []);
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
setLink({ ...link, tags: tagNames });
};
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const submit = async () => {
setSubmitLoader(true);
let response;
const load = toast.loading(
method === "UPDATE" ? "Applying..." : "Creating..."
);
if (method === "UPDATE") response = await updateLink(link);
else response = await addLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(`Link ${method === "UPDATE" ? "Saved!" : "Created!"}`);
toggleLinkModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
};
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{method === "UPDATE" ? (
<div
className="text-neutral break-all w-full flex gap-2"
title={link.url || ""}
>
<i className={"bi-link-45deg"}></i>
<Link href={link.url || ""} target="_blank" className="w-full">
{link.url}
</Link>
</div>
) : null}
{method === "CREATE" ? (
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5">
<p className="mb-2">Address (URL)</p>
<TextInput
value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder="e.g. http://example.com/"
className="bg-base-200"
/>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">Collection</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
// defaultValue={{
// label: link.collection.name,
// value: link.collection.id,
// }}
defaultValue={
link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
}
/>
) : null}
</div>
</div>
) : undefined}
{optionsExpanded ? (
<div>
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}>
<p className="mb-2">Name</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
className="bg-base-200"
/>
</div>
{method === "UPDATE" ? (
<div>
<p className="mb-2">Collection</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.name && link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
}
/>
) : undefined}
</div>
) : undefined}
<div>
<p className="mb-2">Tags</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={
method === "CREATE"
? "Will be auto generated if nothing is provided."
: ""
}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
) : undefined}
<div className="flex justify-between items-stretch mt-2">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`${
method === "UPDATE" ? "hidden" : ""
} rounded-md cursor-pointer btn btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
>
<p>{optionsExpanded ? "Hide" : "More"} Options</p>
</div>
<SubmitButton
onClick={submit}
label={method === "CREATE" ? "Add" : "Save"}
loading={submitLoader}
className={`${method === "CREATE" ? "" : "mx-auto"}`}
/>
</div>
</div>
);
}

View File

@ -1,177 +0,0 @@
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useState } from "react";
import Link from "next/link";
import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import {
pdfAvailable,
screenshotAvailable,
} from "@/lib/shared/getArchiveValidity";
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: any;
if (screenshotAvailable(link) && pdfAvailable(link)) {
let isPublicRoute = router.pathname.startsWith("/public")
? true
: undefined;
interval = setInterval(
() => getLink(link?.id as number, isPublicRoute),
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: ArchivedFormat) => {
const path = `/api/v1/archives/${link?.id}?format=${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 === ArchivedFormat.png ? "Screenshot" : "PDF";
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`}>
{screenshotAvailable(link) ? (
<div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
<div className="flex gap-2 items-center">
<div className="bg-primary text-primary-content p-2 rounded-l-md">
</div>
<p>Screenshot</p>
</div>
<div className="flex gap-1">
<div
onClick={() => handleDownload(ArchivedFormat.png)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
</div>
<Link
href={`/api/v1/archives/${link?.id}?format=${
link?.screenshotPath?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
</Link>
</div>
</div>
) : undefined}
{pdfAvailable(link) ? (
<div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
<div className="flex gap-2 items-center">
<div className="bg-primary text-primary-content p-2 rounded-l-md">
</div>
<p>PDF</p>
</div>
<div className="flex gap-1">
<div
onClick={() => handleDownload(ArchivedFormat.pdf)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
</div>
<Link
href={`/api/v1/archives/${link?.id}?format=${ArchivedFormat.pdf}`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
</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={`btn btn-accent dark:border-violet-400 text-white ${
screenshotAvailable(link) && pdfAvailable(link) ? "mt-3" : ""
}`}
onClick={() => updateArchive()}
>
<div>
<p>Update Preserved Formats</p>
<p className="text-xs">(Refresh Link)</p>
</div>
</div>
) : undefined}
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className={`text-neutral duration-100 hover:opacity-60 flex gap-2 w-fit items-center text-sm ${
screenshotAvailable(link) && pdfAvailable(link) ? "sm:mt-3" : ""
}`}
>
<p className="whitespace-nowrap">
View Latest Snapshot on archive.org
</p>
</Link>
</div>
</div>
);
}

View File

@ -1,63 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import AddOrEditLink from "./AddOrEditLink";
import PreservedFormats from "./PreservedFormats";
type Props =
| {
toggleLinkModal: Function;
method: "CREATE";
activeLink?: LinkIncludingShortenedCollectionAndTags;
className?: string;
}
| {
toggleLinkModal: Function;
method: "UPDATE";
activeLink: LinkIncludingShortenedCollectionAndTags;
className?: string;
}
| {
toggleLinkModal: Function;
method: "FORMATS";
activeLink: LinkIncludingShortenedCollectionAndTags;
className?: string;
};
export default function LinkModal({
className,
toggleLinkModal,
activeLink,
method,
}: Props) {
return (
<div className={className}>
{method === "CREATE" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
Create a New Link
</p>
<AddOrEditLink toggleLinkModal={toggleLinkModal} method="CREATE" />
</>
) : undefined}
{activeLink && method === "UPDATE" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">Edit Link</p>
<AddOrEditLink
toggleLinkModal={toggleLinkModal}
method="UPDATE"
activeLink={activeLink}
/>
</>
) : undefined}
{method === "FORMATS" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
Preserved Formats
</p>
<PreservedFormats />
</>
) : undefined}
</div>
);
}

View File

@ -19,17 +19,6 @@ export default function EditCollectionSharingModal({
onClose, onClose,
activeCollection, activeCollection,
}: Props) { }: Props) {
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
fetchOwner();
setCollection(activeCollection);
}, []);
const [collection, setCollection] = const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection); useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
@ -70,12 +59,25 @@ export default function EditCollectionSharingModal({
const [memberUsername, setMemberUsername] = useState(""); const [memberUsername, setMemberUsername] = useState("");
const [collectionOwner, setCollectionOwner] = useState({ const [collectionOwner, setCollectionOwner] = useState({
id: null, id: null as unknown as number,
name: "", name: "",
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
}); });
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
fetchOwner();
setCollection(activeCollection);
}, []);
const setMemberState = (newMember: Member) => { const setMemberState = (newMember: Member) => {
if (!collection) return null; if (!collection) return null;
@ -174,7 +176,7 @@ export default function EditCollectionSharingModal({
setMemberState setMemberState
) )
} }
className="btn btn-accent text-white btn-square btn-sm h-10 w-10" className="btn btn-accent dark:border-violet-400 text-white btn-square btn-sm h-10 w-10"
> >
<i className="bi-person-add text-xl"></i> <i className="bi-person-add text-xl"></i>
</div> </div>

View File

@ -1,13 +1,22 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { ArchivedFormat, LinkIncludingShortenedCollectionAndTags, } from "@/types/global"; import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import Modal from "../Modal"; import Modal from "../Modal";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { pdfAvailable, readabilityAvailable, screenshotAvailable, } from "@/lib/shared/getArchiveValidity"; import {
pdfAvailable,
readabilityAvailable,
screenshotAvailable,
} from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow"; import PreservedFormatRow from "@/components/PreserverdFormatRow";
import useAccountStore from "@/store/account";
import getPublicUserData from "@/lib/client/getPublicUserData";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -18,27 +27,71 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
const session = useSession(); const session = useSession();
const { getLink } = useLinkStore(); const { getLink } = useLinkStore();
const { account } = useAccountStore();
const [link, setLink] = const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink); useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const router = useRouter(); const router = useRouter();
useEffect(() => { let isPublic = router.pathname.startsWith("/public") ? true : undefined;
let isPublicRoute = router.pathname.startsWith("/public")
? true
: undefined;
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== account.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.collection.ownerId === account.id) {
setCollectionOwner({
id: account.id as number,
name: account.name,
username: account.username as string,
image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [link.collection.ownerId]);
const isReady = () => {
return (
collectionOwner.archiveAsScreenshot ===
(link && link.pdfPath && link.pdfPath !== "pending") &&
collectionOwner.archiveAsPDF ===
(link && link.pdfPath && link.pdfPath !== "pending") &&
link &&
link.readabilityPath &&
link.readabilityPath !== "pending"
);
};
useEffect(() => {
(async () => { (async () => {
const data = await getLink(link.id as number, isPublicRoute); const data = await getLink(link.id as number, isPublic);
setLink( setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags (data as any).response as LinkIncludingShortenedCollectionAndTags
); );
})(); })();
let interval: any; let interval: any;
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") {
if (!isReady()) {
interval = setInterval(async () => { interval = setInterval(async () => {
const data = await getLink(link.id as number, isPublicRoute); const data = await getLink(link.id as number, isPublic);
setLink( setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags (data as any).response as LinkIncludingShortenedCollectionAndTags
); );
@ -68,8 +121,11 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
toast.dismiss(load); toast.dismiss(load);
if (response.ok) { if (response.ok) {
const newLink = await getLink(link?.id as number);
setLink(
(newLink as any).response as LinkIncludingShortenedCollectionAndTags
);
toast.success(`Link is being archived...`); toast.success(`Link is being archived...`);
getLink(link?.id as number);
} else toast.error(data.response); } else toast.error(data.response);
}; };
@ -79,69 +135,98 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
<div className="divider mb-2 mt-1"></div> <div className="divider mb-2 mt-1"></div>
{screenshotAvailable(link) || {isReady() &&
(screenshotAvailable(link) ||
pdfAvailable(link) || pdfAvailable(link) ||
readabilityAvailable(link) ? ( readabilityAvailable(link)) ? (
<p className="mb-3"> <p className="mb-3">
The following formats are available for this link: The following formats are available for this link:
</p> </p>
) : ( ) : (
<p className="mb-3">No preserved formats available.</p> ""
)} )}
<div className={`flex flex-col gap-3`}> <div className={`flex flex-col gap-3`}>
{readabilityAvailable(link) ? ( {isReady() ? (
<PreservedFormatRow name={'Readable'} icon={'bi-file-earmark-text'} format={ArchivedFormat.readability} <>
activeLink={link}/>
) : undefined}
{screenshotAvailable(link) ? ( {screenshotAvailable(link) ? (
<PreservedFormatRow name={'Screenshot'} icon={'bi-file-earmark-image'} format={ArchivedFormat.png} <PreservedFormatRow
activeLink={link} downloadable={true}/> name={"Screenshot"}
icon={"bi-file-earmark-image"}
format={
link?.screenshotPath?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
activeLink={link}
downloadable={true}
/>
) : undefined} ) : undefined}
{pdfAvailable(link) ? ( {pdfAvailable(link) ? (
<PreservedFormatRow name={'PDF'} icon={'bi-file-earmark-pdf'} format={ArchivedFormat.pdf} <PreservedFormatRow
activeLink={link} downloadable={true}/> name={"PDF"}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
activeLink={link}
downloadable={true}
/>
) : undefined} ) : undefined}
<div className="flex flex-col-reverse sm:flex-row sm:gap-3 items-center justify-center"> {readabilityAvailable(link) ? (
{link?.collection.ownerId === session.data?.user.id ? ( <PreservedFormatRow
<div name={"Readable"}
className={`btn btn-accent w-1/2 dark:border-violet-400 text-white ${ icon={"bi-file-earmark-text"}
screenshotAvailable(link) && format={ArchivedFormat.readability}
pdfAvailable(link) && activeLink={link}
readabilityAvailable(link) />
? "mt-3"
: ""
}`}
onClick={() => updateArchive()}
>
<div>
<p>Update Preserved Formats</p>
<p className="text-xs">(Refresh Link)</p>
</div>
</div>
) : undefined} ) : undefined}
</>
) : (
<div
className={`w-full h-full flex flex-col justify-center p-10 skeleton bg-base-200`}
>
<i className="bi-stack drop-shadow text-primary text-8xl mx-auto mb-5"></i>
<p className="text-center text-2xl">
Link preservation is in the queue
</p>
<p className="text-center text-lg">
Please check back later to see the result
</p>
</div>
)}
<div
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${
isReady() ? "sm:mt " : ""
}`}
>
<Link <Link
href={`https://web.archive.org/web/${link?.url?.replace( href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//, /(^\w+:|^)\/\//,
"" ""
)}`} )}`}
target="_blank" target="_blank"
className={`text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm ${ className={`text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm`}
screenshotAvailable(link) &&
pdfAvailable(link) &&
readabilityAvailable(link)
? "sm:mt-3"
: ""
}`}
> >
<p className="whitespace-nowrap"> <p className="whitespace-nowrap">
View latest snapshot on archive.org View latest snapshot on archive.org
</p> </p>
<i className="bi-box-arrow-up-right" /> <i className="bi-box-arrow-up-right" />
</Link> </Link>
{link?.collection.ownerId === session.data?.user.id ? (
<div
className={`btn w-1/2 btn-outline`}
onClick={() => updateArchive()}
>
<div>
<p>Refresh Preserved Formats</p>
<p className="text-xs">
This deletes the current preservations
</p>
</div>
</div>
) : undefined}
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@ -1,49 +0,0 @@
import useModalStore from "@/store/modals";
import Modal from "./Modal";
import LinkModal from "./Modal/Link";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import CollectionModal from "./Modal/Collection";
import { useEffect } from "react";
import { useRouter } from "next/router";
export default function ModalManagement() {
const { modal, setModal } = useModalStore();
const toggleModal = () => {
setModal(null);
};
const router = useRouter();
useEffect(() => {
toggleModal();
}, [router]);
if (modal && modal.modal === "LINK")
return (
<Modal toggleModal={toggleModal}>
<LinkModal
toggleLinkModal={toggleModal}
method={modal.method}
activeLink={modal.active as LinkIncludingShortenedCollectionAndTags}
/>
</Modal>
);
else if (modal && modal.modal === "COLLECTION")
return (
<Modal toggleModal={toggleModal}>
<CollectionModal
toggleCollectionModal={toggleModal}
method={modal.method}
isOwner={modal.isOwner as boolean}
defaultIndex={modal.defaultIndex}
activeCollection={
modal.active as CollectionIncludingMembersAndLinkCount
}
/>
</Modal>
);
else return <></>;
}

View File

@ -10,14 +10,20 @@ import { useRouter } from "next/router";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
type Props = { type Props = {
name: string, name: string;
icon: string, icon: string;
format: ArchivedFormat, format: ArchivedFormat;
activeLink: LinkIncludingShortenedCollectionAndTags, activeLink: LinkIncludingShortenedCollectionAndTags;
downloadable?: boolean, downloadable?: boolean;
}; };
export default function PreservedFormatRow({ name, icon, format, activeLink, downloadable }: Props) { export default function PreservedFormatRow({
name,
icon,
format,
activeLink,
downloadable,
}: Props) {
const session = useSession(); const session = useSession();
const { getLink } = useLinkStore(); const { getLink } = useLinkStore();
@ -26,13 +32,11 @@ export default function PreservedFormatRow({ name, icon, format, activeLink, dow
const router = useRouter(); const router = useRouter();
useEffect(() => { let isPublic = router.pathname.startsWith("/public") ? true : undefined;
let isPublicRoute = router.pathname.startsWith("/public")
? true
: undefined;
useEffect(() => {
(async () => { (async () => {
const data = await getLink(link.id as number, isPublicRoute); const data = await getLink(link.id as number, isPublic);
setLink( setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags (data as any).response as LinkIncludingShortenedCollectionAndTags
); );
@ -41,7 +45,7 @@ export default function PreservedFormatRow({ name, icon, format, activeLink, dow
let interval: any; let interval: any;
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") { if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") {
interval = setInterval(async () => { interval = setInterval(async () => {
const data = await getLink(link.id as number, isPublicRoute); const data = await getLink(link.id as number, isPublic);
setLink( setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags (data as any).response as LinkIncludingShortenedCollectionAndTags
); );
@ -59,23 +63,6 @@ export default function PreservedFormatRow({ name, icon, format, activeLink, dow
}; };
}, [link?.screenshotPath, link?.pdfPath, link?.readabilityPath]); }, [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 = () => { const handleDownload = () => {
const path = `/api/v1/archives/${link?.id}?format=${format}`; const path = `/api/v1/archives/${link?.id}?format=${format}`;
fetch(path) fetch(path)
@ -84,7 +71,7 @@ export default function PreservedFormatRow({ name, icon, format, activeLink, dow
// Create a temporary link and click it to trigger the download // Create a temporary link and click it to trigger the download
const link = document.createElement("a"); const link = document.createElement("a");
link.href = path; link.href = path;
link.download = format === ArchivedFormat.png ? "Screenshot" : "PDF"; link.download = format === ArchivedFormat.pdf ? "PDF" : "Screenshot";
link.click(); link.click();
} else { } else {
console.error("Failed to download file"); console.error("Failed to download file");
@ -108,26 +95,18 @@ export default function PreservedFormatRow({ name, icon, format, activeLink, dow
{downloadable || false ? ( {downloadable || false ? (
<div <div
onClick={() => handleDownload()} onClick={() => handleDownload()}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" className="btn btn-sm btn-square"
> >
<i className="bi-cloud-arrow-down text-xl text-neutral" /> <i className="bi-cloud-arrow-down text-xl text-neutral" />
</div> </div>
) : undefined} ) : undefined}
<Link <Link
href={ href={`${isPublic ? "/public" : ""}/preserved/${
`${ link?.id
format === ArchivedFormat.readability }?format=${format}`}
? `/preserved/${link?.id}?format=${format}`
: `/api/v1/archives/${link?.id}?format=${
link.screenshotPath?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`
}`
}
target="_blank" target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" className="btn btn-sm btn-square"
> >
<i className="bi-box-arrow-up-right text-xl text-neutral" /> <i className="bi-box-arrow-up-right text-xl text-neutral" />
</Link> </Link>

View File

@ -4,6 +4,8 @@ import isValidUrl from "@/lib/shared/isValidUrl";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { TagIncludingLinkCount } from "@/types/global"; import { TagIncludingLinkCount } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react";
import PreservedFormatsModal from "../ModalContent/PreservedFormatsModal";
interface LinksIncludingTags extends LinkType { interface LinksIncludingTags extends LinkType {
tags: TagIncludingLinkCount[]; tags: TagIncludingLinkCount[];
@ -25,6 +27,8 @@ export default function LinkCard({ link, count }: Props) {
day: "numeric", day: "numeric",
}); });
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
return ( return (
<div className="border border-solid border-neutral-content bg-base-200 shadow hover:shadow-none duration-100 rounded-lg p-3 flex items-start relative gap-3 group/item"> <div className="border border-solid border-neutral-content bg-base-200 shadow hover:shadow-none duration-100 rounded-lg p-3 flex items-start relative gap-3 group/item">
<div className="flex justify-between items-end gap-5 w-full h-full z-0"> <div className="flex justify-between items-end gap-5 w-full h-full z-0">
@ -77,15 +81,52 @@ export default function LinkCard({ link, count }: Props) {
<div className="w-full"> <div className="w-full">
{unescapeString(link.description)}{" "} {unescapeString(link.description)}{" "}
<Link <Link
href={`/public/links/${link.id}`} href={link.url || ""}
target="_blank"
className="flex gap-1 items-center flex-wrap text-sm text-neutral hover:opacity-50 duration-100 min-w-fit float-right mt-1 ml-2" className="flex gap-1 items-center flex-wrap text-sm text-neutral hover:opacity-50 duration-100 min-w-fit float-right mt-1 ml-2"
> >
<p>Read</p> <p>Visit</p>
<i className={"bi-chevron-right"}></i> <i className={"bi-chevron-right"}></i>
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div
className={`dropdown dropdown-left absolute ${"top-3 right-3"} z-20`}
>
<div
tabIndex={0}
role="button"
className="btn btn-ghost btn-sm btn-square text-neutral"
>
<i
id={"expand-dropdown" + link.id}
title="More"
className="bi-three-dots text-xl"
/>
</div>
<ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true);
// updateArchive();
}}
>
Preserved Formats
</div>
</li>
</ul>
</div>
{preservedFormatsModal ? (
<PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)}
activeLink={link as any}
/>
) : undefined}
</div> </div>
); );
} }

View File

@ -31,9 +31,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-person text-primary text-2xl"></i>
className="bi-person text-primary text-2xl"
></i>
<p className="truncate w-full pr-7">Account</p> <p className="truncate w-full pr-7">Account</p>
</div> </div>
@ -47,9 +45,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-palette text-primary text-2xl"></i>
className="bi-palette text-primary text-2xl"
></i>
<p className="truncate w-full pr-7">Appearance</p> <p className="truncate w-full pr-7">Appearance</p>
</div> </div>
@ -63,9 +59,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-archive text-primary text-2xl"></i>
className="bi-archive text-primary text-2xl"
></i>
<p className="truncate w-full pr-7">Archive</p> <p className="truncate w-full pr-7">Archive</p>
</div> </div>
</Link> </Link>
@ -78,9 +72,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-key text-primary text-2xl"></i>
className="bi-key text-primary text-2xl"
></i>
<p className="truncate w-full pr-7">API Keys</p> <p className="truncate w-full pr-7">API Keys</p>
</div> </div>
</Link> </Link>
@ -93,9 +85,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-lock text-primary text-2xl"></i>
className="bi-lock text-primary text-2xl"
></i>
<p className="truncate w-full pr-7">Password</p> <p className="truncate w-full pr-7">Password</p>
</div> </div>
</Link> </Link>
@ -109,9 +99,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-credit-card text-primary text-xl"></i>
className="bi-credit-card text-primary text-xl"
></i>
<p className="truncate w-full pr-7">Billing</p> <p className="truncate w-full pr-7">Billing</p>
</div> </div>
</Link> </Link>
@ -130,9 +118,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div <div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-question-circle text-primary text-xl"></i>
className="bi-question-circle text-primary text-xl"
></i>
<p className="truncate w-full pr-7">Help</p> <p className="truncate w-full pr-7">Help</p>
</div> </div>
@ -142,9 +128,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div <div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-github text-primary text-xl"></i>
className="bi-github text-primary text-xl"
></i>
<p className="truncate w-full pr-7">GitHub</p> <p className="truncate w-full pr-7">GitHub</p>
</div> </div>
</Link> </Link>
@ -153,9 +137,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div <div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-twitter-x text-primary text-xl"></i>
className="bi-twitter-x text-primary text-xl"
></i>
<p className="truncate w-full pr-7">Twitter</p> <p className="truncate w-full pr-7">Twitter</p>
</div> </div>
</Link> </Link>
@ -164,9 +146,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div <div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i <i className="bi-mastodon text-primary text-xl"></i>
className="bi-mastodon text-primary text-xl"
></i>
<p className="truncate w-full pr-7">Mastodon</p> <p className="truncate w-full pr-7">Mastodon</p>
</div> </div>
</Link> </Link>

View File

@ -44,7 +44,7 @@ export default function Sidebar({ className }: { className?: string }) {
return ( return (
<div <div
id="sidebar" id="sidebar"
className={`bg-base-200 h-full w-64 xl:w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${ className={`bg-base-200 h-full w-72 lg:w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
className || "" className || ""
}`} }`}
> >
@ -70,7 +70,7 @@ export default function Sidebar({ className }: { className?: string }) {
<SidebarHighlightLink <SidebarHighlightLink
title={"All Collections"} title={"All Collections"}
href={`/collections`} href={`/collections`}
icon={"bi-folder2"} icon={"bi-folder"}
active={active === `/collections`} active={active === `/collections`}
/> />
</div> </div>

View File

@ -28,9 +28,7 @@ export default function SidebarHighlightLink({
<i className={`${icon} text-primary text-2xl drop-shadow`}></i> <i className={`${icon} text-primary text-2xl drop-shadow`}></i>
</div> </div>
<div className={"mt-1"}> <div className={"mt-1"}>
<p className="truncate w-full text-xs font-semibold xl:text-sm"> <p className="truncate w-full font-semibold text-sm">{title}</p>
{title}
</p>
</div> </div>
</div> </div>
</Link> </Link>

View File

@ -2,8 +2,6 @@ import Navbar from "@/components/Navbar";
import AnnouncementBar from "@/components/AnnouncementBar"; import AnnouncementBar from "@/components/AnnouncementBar";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals";
import getLatestVersion from "@/lib/client/getLatestVersion"; import getLatestVersion from "@/lib/client/getLatestVersion";
interface Props { interface Props {
@ -11,14 +9,6 @@ interface Props {
} }
export default function MainLayout({ children }: Props) { export default function MainLayout({ children }: Props) {
const { modal } = useModalStore();
useEffect(() => {
modal
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "auto");
}, [modal]);
const showAnnouncementBar = localStorage.getItem("showAnnouncementBar"); const showAnnouncementBar = localStorage.getItem("showAnnouncementBar");
const [showAnnouncement, setShowAnnouncement] = useState( const [showAnnouncement, setShowAnnouncement] = useState(
showAnnouncementBar ? showAnnouncementBar === "true" : true showAnnouncementBar ? showAnnouncementBar === "true" : true
@ -44,8 +34,6 @@ export default function MainLayout({ children }: Props) {
return ( return (
<> <>
<ModalManagement />
{showAnnouncement ? ( {showAnnouncement ? (
<AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} /> <AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} />
) : undefined} ) : undefined}
@ -60,7 +48,7 @@ export default function MainLayout({ children }: Props) {
<div <div
className={`w-full flex flex-col min-h-${ className={`w-full flex flex-col min-h-${
showAnnouncement ? "full" : "screen" showAnnouncement ? "full" : "screen"
} lg:ml-64 xl:ml-80 ${showAnnouncement ? "mt-10" : ""}`} } lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
> >
<Navbar /> <Navbar />
{children} {children}

View File

@ -1,7 +1,5 @@
import SettingsSidebar from "@/components/SettingsSidebar"; import SettingsSidebar from "@/components/SettingsSidebar";
import React, { ReactNode, useEffect, useState } from "react"; import React, { ReactNode, useEffect, useState } from "react";
import ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import ClickAwayHandler from "@/components/ClickAwayHandler"; import ClickAwayHandler from "@/components/ClickAwayHandler";
import Link from "next/link"; import Link from "next/link";
@ -12,16 +10,8 @@ interface Props {
} }
export default function SettingsLayout({ children }: Props) { export default function SettingsLayout({ children }: Props) {
const { modal } = useModalStore();
const router = useRouter(); const router = useRouter();
useEffect(() => {
modal
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "auto");
}, [modal]);
const [sidebar, setSidebar] = useState(false); const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
@ -40,8 +30,6 @@ export default function SettingsLayout({ children }: Props) {
return ( return (
<> <>
<ModalManagement />
<div className="flex max-w-screen-md mx-auto"> <div className="flex max-w-screen-md mx-auto">
<div className="hidden lg:block fixed h-screen"> <div className="hidden lg:block fixed h-screen">
<SettingsSidebar /> <SettingsSidebar />

View File

@ -7,6 +7,7 @@ import { JSDOM } from "jsdom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Collection, Link, User } from "@prisma/client"; import { Collection, Link, User } from "@prisma/client";
import validateUrlSize from "./validateUrlSize"; import validateUrlSize from "./validateUrlSize";
import removeFile from "./storage/removeFile";
type LinksAndCollectionAndOwner = Link & { type LinksAndCollectionAndOwner = Link & {
collection: Collection & { collection: Collection & {
@ -45,9 +46,18 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
where: { id: link.id }, where: { id: link.id },
data: { data: {
type: linkType, type: linkType,
screenshotPath: user.archiveAsScreenshot ? "pending" : undefined, screenshotPath:
pdfPath: user.archiveAsPDF ? "pending" : undefined, user.archiveAsScreenshot &&
readabilityPath: "pending", !link.screenshotPath?.startsWith("archive")
? "pending"
: undefined,
pdfPath:
user.archiveAsPDF && !link.pdfPath?.startsWith("archive")
? "pending"
: undefined,
readabilityPath: !link.readabilityPath?.startsWith("archive")
? "pending"
: undefined,
lastPreserved: new Date().toISOString(), lastPreserved: new Date().toISOString(),
}, },
}); });
@ -65,7 +75,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
const content = await page.content(); const content = await page.content();
// TODO Webarchive // TODO single file
// const session = await page.context().newCDPSession(page); // const session = await page.context().newCDPSession(page);
// const doc = await session.send("Page.captureSnapshot", { // const doc = await session.send("Page.captureSnapshot", {
// format: "mhtml", // format: "mhtml",
@ -180,6 +190,13 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
: undefined, : undefined,
}, },
}); });
else {
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
removeFile({
filePath: `archives/${link.collectionId}/${link.id}_readability.json`,
});
}
await browser.close(); await browser.close();
} }

View File

@ -6,6 +6,8 @@ import getPermission from "@/lib/api/getPermission";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
import validateUrlSize from "../../validateUrlSize"; import validateUrlSize from "../../validateUrlSize";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
export default async function postLink( export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags, link: LinkIncludingShortenedCollectionAndTags,
userId: number userId: number
@ -24,6 +26,20 @@ export default async function postLink(
link.collection.name = "Unorganized"; link.collection.name = "Unorganized";
} }
const numberOfLinksTheUserHas = await prisma.link.count({
where: {
collection: {
ownerId: userId,
},
},
});
if (numberOfLinksTheUserHas + 1 > MAX_LINKS_PER_USER)
return {
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
link.collection.name = link.collection.name.trim(); link.collection.name = link.collection.name.trim();
if (link.collection.id) { if (link.collection.id) {

View File

@ -1,8 +1,9 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { Backup } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
export default async function importFromHTMLFile( export default async function importFromHTMLFile(
userId: number, userId: number,
rawData: string rawData: string
@ -10,6 +11,23 @@ export default async function importFromHTMLFile(
const dom = new JSDOM(rawData); const dom = new JSDOM(rawData);
const document = dom.window.document; const document = dom.window.document;
const bookmarks = document.querySelectorAll("A");
const totalImports = bookmarks.length;
const numberOfLinksTheUserHas = await prisma.link.count({
where: {
collection: {
ownerId: userId,
},
},
});
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
const folders = document.querySelectorAll("H3"); const folders = document.querySelectorAll("H3");
await prisma await prisma

View File

@ -2,9 +2,34 @@ import { prisma } from "@/lib/api/db";
import { Backup } from "@/types/global"; import { Backup } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
export default async function getData(userId: number, rawData: string) { const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
export default async function importFromLinkwarden(
userId: number,
rawData: string
) {
const data: Backup = JSON.parse(rawData); const data: Backup = JSON.parse(rawData);
let totalImports = 0;
data.collections.forEach((collection) => {
totalImports += collection.links.length;
});
const numberOfLinksTheUserHas = await prisma.link.count({
where: {
collection: {
ownerId: userId,
},
},
});
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
await prisma await prisma
.$transaction( .$transaction(
async () => { async () => {

View File

@ -74,6 +74,8 @@ export default async function getPublicUser(
name: lessSensitiveInfo.name, name: lessSensitiveInfo.name,
username: lessSensitiveInfo.username, username: lessSensitiveInfo.username,
image: lessSensitiveInfo.image, image: lessSensitiveInfo.image,
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
archiveAsPDF: lessSensitiveInfo.archiveAsPDF,
}; };
return { response: data, status: 200 }; return { response: data, status: 200 };

View File

@ -1,8 +1,9 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import archiveHandler from "@/lib/api/archiveHandler";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import removeFile from "@/lib/api/storage/removeFile";
import { Collection, Link } from "@prisma/client";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@ -42,20 +43,17 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
} minutes or create a new one.`, } minutes or create a new one.`,
}); });
if (link.url && isValidUrl(link.url)) { if (!link.url || !isValidUrl(link.url))
archiveHandler(link);
return res.status(200).json({ return res.status(200).json({
response: "Link is not a webpage to be archived.", response: "Invalid URL.",
}); });
}
await deleteArchivedFiles(link);
return res.status(200).json({ return res.status(200).json({
response: "Link is being archived.", response: "Link is being archived.",
}); });
} }
// TODO - Later?
// else if (req.method === "DELETE") {}
} }
const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => { const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
@ -68,3 +66,26 @@ const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
return diffInMinutes; return diffInMinutes;
}; };
const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
await prisma.link.update({
where: {
id: link.id,
},
data: {
screenshotPath: null,
pdfPath: null,
readabilityPath: null,
},
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}.pdf`,
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}.png`,
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
});
};

View File

@ -55,6 +55,8 @@ export default function Index() {
name: "", name: "",
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
}); });
useEffect(() => { useEffect(() => {
@ -70,6 +72,8 @@ export default function Index() {
name: account.name, name: account.name,
username: account.username as string, username: account.username as string,
image: account.image as string, image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean,
}); });
} }
}; };
@ -118,7 +122,7 @@ export default function Index() {
</p> </p>
</div> </div>
<div className="dropdown dropdown-bottom dropdown-end"> <div className="dropdown dropdown-bottom dropdown-end mt-2">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"

View File

@ -56,19 +56,11 @@ export default function Collections() {
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] ? ( {sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] ? (
<> <>
<div className="flex items-center gap-3 my-5"> <PageHeader
<i className="bi-folder text-3xl sm:text-2xl text-primary drop-shadow"></i> icon={"bi-folder"}
title={"Other Collections"}
<div> description={"Shared collections you're a member of"}
<p className="text-3xl capitalize font-thin"> />
Other Collections
</p>
<p className="sm:text-sm text-xs">
Shared collections you&apos;re a member of
</p>
</div>
</div>
<div className="grid xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <div className="grid xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections {sortedCollections

View File

@ -8,7 +8,6 @@ import useLinks from "@/hooks/useLinks";
import Link from "next/link"; import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions"; import useWindowDimensions from "@/hooks/useWindowDimensions";
import React from "react"; import React from "react";
import useModalStore from "@/store/modals";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { MigrationFormat, MigrationRequest } from "@/types/global"; import { MigrationFormat, MigrationRequest } from "@/types/global";
import DashboardItem from "@/components/DashboardItem"; import DashboardItem from "@/components/DashboardItem";
@ -20,8 +19,6 @@ export default function Dashboard() {
const { links } = useLinkStore(); const { links } = useLinkStore();
const { tags } = useTagStore(); const { tags } = useTagStore();
const { setModal } = useModalStore();
const [numberOfLinks, setNumberOfLinks] = useState(0); const [numberOfLinks, setNumberOfLinks] = useState(0);
const [showLinks, setShowLinks] = useState(3); const [showLinks, setShowLinks] = useState(3);
@ -100,14 +97,14 @@ export default function Dashboard() {
description={"A brief overview of your data"} description={"A brief overview of your data"}
/> />
<div> <div>
<div className="flex justify-evenly flex-col md:flex-row md:items-center gap-2 md:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200"> <div className="flex justify-evenly flex-col xl:flex-row xl:items-center gap-2 xl:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200">
<DashboardItem <DashboardItem
name={numberOfLinks === 1 ? "Link" : "Links"} name={numberOfLinks === 1 ? "Link" : "Links"}
value={numberOfLinks} value={numberOfLinks}
icon={"bi-link-45deg"} icon={"bi-link-45deg"}
/> />
<div className="divider md:divider-horizontal"></div> <div className="divider xl:divider-horizontal"></div>
<DashboardItem <DashboardItem
name={collections.length === 1 ? "Collection" : "Collections"} name={collections.length === 1 ? "Collection" : "Collections"}
@ -115,7 +112,7 @@ export default function Dashboard() {
icon={"bi-folder"} icon={"bi-folder"}
/> />
<div className="divider md:divider-horizontal"></div> <div className="divider xl:divider-horizontal"></div>
<DashboardItem <DashboardItem
name={tags.length === 1 ? "Tag" : "Tags"} name={tags.length === 1 ? "Tag" : "Tags"}
@ -127,8 +124,11 @@ export default function Dashboard() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<i className="bi-clock-history text-primary text-2xl drop-shadow"></i> <PageHeader
<p className="text-2xl">Recent</p> icon={"bi-clock-history"}
title={"Recent"}
description={"Recently added Links"}
/>
</div> </div>
<Link <Link
href="/links" href="/links"
@ -171,7 +171,7 @@ export default function Dashboard() {
onClick={() => { onClick={() => {
setNewLinkModal(true); setNewLinkModal(true);
}} }}
className="inline-flex items-center gap-2 text-sm btn btn-accent dark:border-accent text-white" className="inline-flex items-center gap-2 text-sm btn btn-accent dark:border-violet-400 text-white"
> >
<i className="bi-plus-lg text-xl duration-100"></i> <i className="bi-plus-lg text-xl duration-100"></i>
<span className="group-hover:opacity-0 text-right duration-100"> <span className="group-hover:opacity-0 text-right duration-100">
@ -239,8 +239,11 @@ export default function Dashboard() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<i className="bi-pin-angle text-primary text-2xl drop-shadow"></i> <PageHeader
<p className="text-2xl">Pinned</p> icon={"bi-pin-angle"}
title={"Pinned"}
description={"Your pinned Links"}
/>
</div> </div>
<Link <Link
href="/links/pinned" href="/links/pinned"

View File

@ -1,7 +1,10 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import ReadableView from "@/components/ReadableView"; import ReadableView from "@/components/ReadableView";
export default function Index() { export default function Index() {
@ -30,7 +33,29 @@ export default function Index() {
{/* <div className="fixed left-1/2 transform -translate-x-1/2 w-fit py-1 px-3 bg-base-200 border border-neutral-content rounded-md"> {/* <div className="fixed left-1/2 transform -translate-x-1/2 w-fit py-1 px-3 bg-base-200 border border-neutral-content rounded-md">
Readable Readable
</div> */} </div> */}
{link && <ReadableView link={link} />} {link && Number(router.query.format) === ArchivedFormat.readability && (
<ReadableView link={link} />
)}
{link && Number(router.query.format) === ArchivedFormat.pdf && (
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}`}
className="w-full h-screen border-none"
></iframe>
)}
{link && Number(router.query.format) === ArchivedFormat.png && (
<img
alt=""
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.png}`}
className="w-fit mx-auto"
/>
)}
{link && Number(router.query.format) === ArchivedFormat.jpeg && (
<img
alt=""
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}`}
className="w-fit mx-auto"
/>
)}
</div> </div>
); );
} }

View File

@ -9,7 +9,6 @@ import Head from "next/head";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import ProfilePhoto from "@/components/ProfilePhoto"; import ProfilePhoto from "@/components/ProfilePhoto";
import ModalManagement from "@/components/ModalManagement";
import ToggleDarkMode from "@/components/ToggleDarkMode"; import ToggleDarkMode from "@/components/ToggleDarkMode";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import Image from "next/image"; import Image from "next/image";
@ -42,10 +41,12 @@ export default function PublicCollections() {
const router = useRouter(); const router = useRouter();
const [collectionOwner, setCollectionOwner] = useState({ const [collectionOwner, setCollectionOwner] = useState({
id: null, id: null as unknown as number,
name: "", name: "",
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
}); });
const [searchFilter, setSearchFilter] = useState({ const [searchFilter, setSearchFilter] = useState({
@ -95,15 +96,13 @@ export default function PublicCollections() {
return collection ? ( return collection ? (
<div <div
className="h-screen" className="h-96"
style={{ style={{
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${ backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
settings.theme === "dark" ? "#262626" : "#f3f4f6" settings.theme === "dark" ? "#262626" : "#f3f4f6"
} 18rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
}} }}
> >
<ModalManagement />
{collection ? ( {collection ? (
<Head> <Head>
<title>{collection.name} | Linkwarden</title> <title>{collection.name} | Linkwarden</title>
@ -209,6 +208,11 @@ export default function PublicCollections() {
{links {links
?.filter((e) => e.collectionId === Number(router.query.id)) ?.filter((e) => e.collectionId === Number(router.query.id))
.map((e, i) => { .map((e, i) => {
const linkWithCollectionData = {
...e,
collection: collection, // Append collection data
};
return ( return (
<motion.div <motion.div
key={i} key={i}
@ -217,7 +221,10 @@ export default function PublicCollections() {
viewport={{ once: true, amount: 0.8 }} viewport={{ once: true, amount: 0.8 }}
> >
<motion.div variants={cardVariants}> <motion.div variants={cardVariants}>
<PublicLinkCard link={e as any} count={i} /> <PublicLinkCard
link={linkWithCollectionData as any}
count={i}
/>
</motion.div> </motion.div>
</motion.div> </motion.div>
); );

View File

@ -0,0 +1,63 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { useRouter } from "next/router";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import ReadableView from "@/components/ReadableView";
export default function Index() {
const { links, getLink } = useLinkStore();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : false;
useEffect(() => {
const fetchLink = async () => {
if (router.query.id) {
await getLink(Number(router.query.id), isPublic);
}
};
fetchLink();
}, []);
useEffect(() => {
if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
return (
<div className="relative">
{/* <div className="fixed left-1/2 transform -translate-x-1/2 w-fit py-1 px-3 bg-base-200 border border-neutral-content rounded-md">
Readable
</div> */}
{link && Number(router.query.format) === ArchivedFormat.readability && (
<ReadableView link={link} />
)}
{link && Number(router.query.format) === ArchivedFormat.pdf && (
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}`}
className="w-full h-screen border-none"
></iframe>
)}
{link && Number(router.query.format) === ArchivedFormat.png && (
<img
alt=""
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.png}`}
className="w-fit mx-auto"
/>
)}
{link && Number(router.query.format) === ArchivedFormat.jpeg && (
<img
alt=""
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}`}
className="w-fit mx-auto"
/>
)}
</div>
);
}

View File

@ -10,7 +10,6 @@ import SubmitButton from "@/components/SubmitButton";
import React from "react"; import React from "react";
import { MigrationFormat, MigrationRequest } from "@/types/global"; import { MigrationFormat, MigrationRequest } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import Checkbox from "@/components/Checkbox"; import Checkbox from "@/components/Checkbox";
export default function Account() { export default function Account() {
@ -85,9 +84,9 @@ export default function Account() {
setSubmitLoader(false); setSubmitLoader(false);
}; };
const [importDropdown, setImportDropdown] = useState(false);
const importBookmarks = async (e: any, format: MigrationFormat) => { const importBookmarks = async (e: any, format: MigrationFormat) => {
setSubmitLoader(true);
const file: File = e.target.files[0]; const file: File = e.target.files[0];
if (file) { if (file) {
@ -112,18 +111,19 @@ export default function Account() {
toast.dismiss(load); toast.dismiss(load);
if (response.ok) {
toast.success("Imported the Bookmarks! Reloading the page..."); toast.success("Imported the Bookmarks! Reloading the page...");
setImportDropdown(false);
setTimeout(() => { setTimeout(() => {
location.reload(); location.reload();
}, 2000); }, 2000);
} else toast.error(data.response as string);
}; };
reader.onerror = function (e) { reader.onerror = function (e) {
console.log("Error:", e); console.log("Error:", e);
}; };
} }
setSubmitLoader(false);
}; };
const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState(""); const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState("");

View File

@ -26,7 +26,7 @@ const s3Client =
async function checkFileExistence(path) { async function checkFileExistence(path) {
if (s3Client) { if (s3Client) {
const bucketParams = { const bucketParams = {
Bucket: process.env.BUCKET_NAME, Bucket: process.env.SPACES_BUCKET_NAME,
Key: path, Key: path,
}; };

View File

@ -10,6 +10,7 @@ declare global {
AUTOSCROLL_TIMEOUT?: string; AUTOSCROLL_TIMEOUT?: string;
RE_ARCHIVE_LIMIT?: string; RE_ARCHIVE_LIMIT?: string;
NEXT_PUBLIC_MAX_FILE_SIZE?: string; NEXT_PUBLIC_MAX_FILE_SIZE?: string;
MAX_LINKS_PER_USER?: string;
SPACES_KEY?: string; SPACES_KEY?: string;
SPACES_SECRET?: string; SPACES_SECRET?: string;