Merge branch 'dev' into collection-sharing-layout
This commit is contained in:
commit
31cf3c4f01
|
@ -16,6 +16,7 @@ NEXT_PUBLIC_CREDENTIALS_ENABLED=
|
|||
DISABLE_NEW_SSO_USERS=
|
||||
RE_ARCHIVE_LIMIT=
|
||||
NEXT_PUBLIC_MAX_FILE_SIZE=
|
||||
MAX_LINKS_PER_USER=
|
||||
|
||||
# AWS S3 Settings
|
||||
SPACES_KEY=
|
||||
|
|
|
@ -35,6 +35,8 @@ export default function CollectionCard({ collection, className }: Props) {
|
|||
name: "",
|
||||
username: "",
|
||||
image: "",
|
||||
archiveAsScreenshot: undefined as unknown as boolean,
|
||||
archiveAsPDF: undefined as unknown as boolean,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -48,6 +50,8 @@ export default function CollectionCard({ collection, className }: Props) {
|
|||
name: account.name,
|
||||
username: account.username 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>
|
||||
</div>
|
||||
<ul
|
||||
className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
|
||||
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
|
||||
{permissions === true ? (
|
||||
<li>
|
||||
<div
|
||||
|
@ -166,17 +169,23 @@ export default function CollectionCard({ collection, className }: Props) {
|
|||
<div className="text-right">
|
||||
<div className="font-bold text-sm flex justify-end gap-1 items-center">
|
||||
{collection.isPublic ? (
|
||||
<i className="bi-globe-americas drop-shadow text-neutral"
|
||||
title="This collection is being shared publicly."></i>
|
||||
<i
|
||||
className="bi-globe-americas drop-shadow text-neutral"
|
||||
title="This collection is being shared publicly."
|
||||
></i>
|
||||
) : undefined}
|
||||
<i className="bi-link-45deg text-lg text-neutral"
|
||||
title="This collection is being shared publicly."></i>
|
||||
<i
|
||||
className="bi-link-45deg text-lg text-neutral"
|
||||
title="This collection is being shared publicly."
|
||||
></i>
|
||||
{collection._count && collection._count.links}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1 text-neutral">
|
||||
<p className="font-bold text-xs flex gap-1 items-center">
|
||||
<i className="bi-calendar3 text-neutral"
|
||||
title="This collection is being shared publicly."></i>
|
||||
<i
|
||||
className="bi-calendar3 text-neutral"
|
||||
title="This collection is being shared publicly."
|
||||
></i>
|
||||
{formattedDate}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -9,7 +9,7 @@ export default function dashboardItem({
|
|||
}) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-col justify-center">
|
||||
|
|
|
@ -15,7 +15,7 @@ type Props = {
|
|||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
position?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export default function LinkActions({ link, collection, position }: Props) {
|
||||
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 [deleteLinkModal, setDeleteLinkModal] = useState(false);
|
||||
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
|
||||
const [expandedLink, setExpandedLink] = useState(false);
|
||||
|
||||
const { account } = useAccountStore();
|
||||
|
||||
|
@ -42,7 +41,7 @@ export default function LinkActions({ link, collection, position }: Props) {
|
|||
toast.dismiss(load);
|
||||
|
||||
response.ok &&
|
||||
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
|
||||
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
|
||||
};
|
||||
|
||||
const deleteLink = async () => {
|
||||
|
@ -57,85 +56,82 @@ export default function LinkActions({ link, collection, position }: Props) {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete ? (
|
||||
<div
|
||||
className={`dropdown dropdown-left absolute ${
|
||||
position || "top-3 right-3"
|
||||
} z-20`}
|
||||
>
|
||||
<div
|
||||
className={`dropdown dropdown-left absolute ${
|
||||
position || "top-3 right-3"
|
||||
} z-20`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||
>
|
||||
<i id={"expand-dropdown" + collection.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">
|
||||
{permissions === true ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
pinLink();
|
||||
}}
|
||||
>
|
||||
{link?.pinnedBy && link.pinnedBy[0]
|
||||
? "Unpin"
|
||||
: "Pin to Dashboard"}
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
{permissions === true || permissions?.canUpdate ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditLinkModal(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
{permissions === true ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setPreservedFormatsModal(true);
|
||||
// updateArchive();
|
||||
}}
|
||||
>
|
||||
Preserved Formats
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
{permissions === true || permissions?.canDelete ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
</ul>
|
||||
<i
|
||||
id={"expand-dropdown" + collection.id}
|
||||
title="More"
|
||||
className="bi-three-dots text-xl"
|
||||
/>
|
||||
</div>
|
||||
) : undefined}
|
||||
<ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
|
||||
{permissions === true ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
pinLink();
|
||||
}}
|
||||
>
|
||||
{link?.pinnedBy && link.pinnedBy[0]
|
||||
? "Unpin"
|
||||
: "Pin to Dashboard"}
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
{permissions === true || permissions?.canUpdate ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditLinkModal(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setPreservedFormatsModal(true);
|
||||
// updateArchive();
|
||||
}}
|
||||
>
|
||||
Preserved Formats
|
||||
</div>
|
||||
</li>
|
||||
{permissions === true || permissions?.canDelete ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{editLinkModal ? (
|
||||
<EditLinkModal
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { MouseEventHandler, ReactNode } from "react";
|
||||
import React, { MouseEventHandler, ReactNode, useEffect } from "react";
|
||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||
|
||||
type Props = {
|
||||
|
@ -8,6 +8,13 @@ type Props = {
|
|||
};
|
||||
|
||||
export default function Modal({ toggleModal, className, children }: Props) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
});
|
||||
|
||||
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">
|
||||
<ClickAwayHandler
|
||||
|
@ -21,9 +28,7 @@ export default function Modal({ toggleModal, className, children }: Props) {
|
|||
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
|
||||
className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
|
||||
>
|
||||
<i
|
||||
className="bi-x text-neutral text-2xl"
|
||||
></i>
|
||||
<i className="bi-x text-neutral text-2xl"></i>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 "
|
||||
<span className="font-bold">{collection.name}</span>
|
||||
" 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -19,17 +19,6 @@ export default function EditCollectionSharingModal({
|
|||
onClose,
|
||||
activeCollection,
|
||||
}: Props) {
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
const owner = await getPublicUserData(collection.ownerId as number);
|
||||
setCollectionOwner(owner);
|
||||
};
|
||||
|
||||
fetchOwner();
|
||||
|
||||
setCollection(activeCollection);
|
||||
}, []);
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||
|
||||
|
@ -70,12 +59,25 @@ export default function EditCollectionSharingModal({
|
|||
const [memberUsername, setMemberUsername] = useState("");
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState({
|
||||
id: null,
|
||||
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 () => {
|
||||
const owner = await getPublicUserData(collection.ownerId as number);
|
||||
setCollectionOwner(owner);
|
||||
};
|
||||
|
||||
fetchOwner();
|
||||
|
||||
setCollection(activeCollection);
|
||||
}, []);
|
||||
|
||||
const setMemberState = (newMember: Member) => {
|
||||
if (!collection) return null;
|
||||
|
||||
|
@ -174,7 +176,7 @@ export default function EditCollectionSharingModal({
|
|||
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>
|
||||
</div>
|
||||
|
|
|
@ -1,13 +1,22 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { ArchivedFormat, LinkIncludingShortenedCollectionAndTags, } from "@/types/global";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import Modal from "../Modal";
|
||||
import { useRouter } from "next/router";
|
||||
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 useAccountStore from "@/store/account";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -18,27 +27,71 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
const session = useSession();
|
||||
const { getLink } = useLinkStore();
|
||||
|
||||
const { account } = useAccountStore();
|
||||
|
||||
const [link, setLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let isPublicRoute = router.pathname.startsWith("/public")
|
||||
? true
|
||||
: undefined;
|
||||
let isPublic = 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 () => {
|
||||
const data = await getLink(link.id as number, isPublicRoute);
|
||||
const data = await getLink(link.id as number, isPublic);
|
||||
setLink(
|
||||
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||
);
|
||||
})();
|
||||
|
||||
let interval: any;
|
||||
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") {
|
||||
|
||||
if (!isReady()) {
|
||||
interval = setInterval(async () => {
|
||||
const data = await getLink(link.id as number, isPublicRoute);
|
||||
const data = await getLink(link.id as number, isPublic);
|
||||
setLink(
|
||||
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||
);
|
||||
|
@ -68,8 +121,11 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
const newLink = await getLink(link?.id as number);
|
||||
setLink(
|
||||
(newLink as any).response as LinkIncludingShortenedCollectionAndTags
|
||||
);
|
||||
toast.success(`Link is being archived...`);
|
||||
getLink(link?.id as number);
|
||||
} else toast.error(data.response);
|
||||
};
|
||||
|
||||
|
@ -79,69 +135,98 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
|
||||
<div className="divider mb-2 mt-1"></div>
|
||||
|
||||
{screenshotAvailable(link) ||
|
||||
pdfAvailable(link) ||
|
||||
readabilityAvailable(link) ? (
|
||||
{isReady() &&
|
||||
(screenshotAvailable(link) ||
|
||||
pdfAvailable(link) ||
|
||||
readabilityAvailable(link)) ? (
|
||||
<p className="mb-3">
|
||||
The following formats are available for this link:
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-3">No preserved formats available.</p>
|
||||
""
|
||||
)}
|
||||
|
||||
<div className={`flex flex-col gap-3`}>
|
||||
{readabilityAvailable(link) ? (
|
||||
<PreservedFormatRow name={'Readable'} icon={'bi-file-earmark-text'} format={ArchivedFormat.readability}
|
||||
activeLink={link}/>
|
||||
) : undefined}
|
||||
{isReady() ? (
|
||||
<>
|
||||
{screenshotAvailable(link) ? (
|
||||
<PreservedFormatRow
|
||||
name={"Screenshot"}
|
||||
icon={"bi-file-earmark-image"}
|
||||
format={
|
||||
link?.screenshotPath?.endsWith("png")
|
||||
? ArchivedFormat.png
|
||||
: ArchivedFormat.jpeg
|
||||
}
|
||||
activeLink={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
{screenshotAvailable(link) ? (
|
||||
<PreservedFormatRow name={'Screenshot'} icon={'bi-file-earmark-image'} format={ArchivedFormat.png}
|
||||
activeLink={link} downloadable={true}/>
|
||||
) : undefined}
|
||||
{pdfAvailable(link) ? (
|
||||
<PreservedFormatRow
|
||||
name={"PDF"}
|
||||
icon={"bi-file-earmark-pdf"}
|
||||
format={ArchivedFormat.pdf}
|
||||
activeLink={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
{pdfAvailable(link) ? (
|
||||
<PreservedFormatRow name={'PDF'} icon={'bi-file-earmark-pdf'} format={ArchivedFormat.pdf}
|
||||
activeLink={link} downloadable={true}/>
|
||||
) : undefined}
|
||||
{readabilityAvailable(link) ? (
|
||||
<PreservedFormatRow
|
||||
name={"Readable"}
|
||||
icon={"bi-file-earmark-text"}
|
||||
format={ArchivedFormat.readability}
|
||||
activeLink={link}
|
||||
/>
|
||||
) : 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-reverse sm:flex-row sm:gap-3 items-center justify-center">
|
||||
{link?.collection.ownerId === session.data?.user.id ? (
|
||||
<div
|
||||
className={`btn btn-accent w-1/2 dark:border-violet-400 text-white ${
|
||||
screenshotAvailable(link) &&
|
||||
pdfAvailable(link) &&
|
||||
readabilityAvailable(link)
|
||||
? "mt-3"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => updateArchive()}
|
||||
>
|
||||
<div>
|
||||
<p>Update Preserved Formats</p>
|
||||
<p className="text-xs">(Refresh Link)</p>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
<div
|
||||
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${
|
||||
isReady() ? "sm:mt " : ""
|
||||
}`}
|
||||
>
|
||||
<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-1/2 justify-center items-center text-sm ${
|
||||
screenshotAvailable(link) &&
|
||||
pdfAvailable(link) &&
|
||||
readabilityAvailable(link)
|
||||
? "sm:mt-3"
|
||||
: ""
|
||||
}`}
|
||||
className={`text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm`}
|
||||
>
|
||||
<p className="whitespace-nowrap">
|
||||
View latest snapshot on archive.org
|
||||
</p>
|
||||
<i className="bi-box-arrow-up-right"/>
|
||||
<i className="bi-box-arrow-up-right" />
|
||||
</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>
|
||||
</Modal>
|
||||
|
|
|
@ -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 <></>;
|
||||
}
|
|
@ -10,14 +10,20 @@ import { useRouter } from "next/router";
|
|||
import { useSession } from "next-auth/react";
|
||||
|
||||
type Props = {
|
||||
name: string,
|
||||
icon: string,
|
||||
format: ArchivedFormat,
|
||||
activeLink: LinkIncludingShortenedCollectionAndTags,
|
||||
downloadable?: boolean,
|
||||
name: string;
|
||||
icon: string;
|
||||
format: ArchivedFormat;
|
||||
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||
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 { getLink } = useLinkStore();
|
||||
|
||||
|
@ -26,13 +32,11 @@ export default function PreservedFormatRow({ name, icon, format, activeLink, dow
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let isPublicRoute = router.pathname.startsWith("/public")
|
||||
? true
|
||||
: undefined;
|
||||
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const data = await getLink(link.id as number, isPublicRoute);
|
||||
const data = await getLink(link.id as number, isPublic);
|
||||
setLink(
|
||||
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||
);
|
||||
|
@ -41,7 +45,7 @@ export default function PreservedFormatRow({ name, icon, format, activeLink, dow
|
|||
let interval: any;
|
||||
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") {
|
||||
interval = setInterval(async () => {
|
||||
const data = await getLink(link.id as number, isPublicRoute);
|
||||
const data = await getLink(link.id as number, isPublic);
|
||||
setLink(
|
||||
(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]);
|
||||
|
||||
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 path = `/api/v1/archives/${link?.id}?format=${format}`;
|
||||
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
|
||||
const link = document.createElement("a");
|
||||
link.href = path;
|
||||
link.download = format === ArchivedFormat.png ? "Screenshot" : "PDF";
|
||||
link.download = format === ArchivedFormat.pdf ? "PDF" : "Screenshot";
|
||||
link.click();
|
||||
} else {
|
||||
console.error("Failed to download file");
|
||||
|
@ -99,7 +86,7 @@ export default function PreservedFormatRow({ name, icon, format, activeLink, dow
|
|||
<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">
|
||||
<i className={`${icon} text-2xl`}/>
|
||||
<i className={`${icon} text-2xl`} />
|
||||
</div>
|
||||
<p>{name}</p>
|
||||
</div>
|
||||
|
@ -108,28 +95,20 @@ export default function PreservedFormatRow({ name, icon, format, activeLink, dow
|
|||
{downloadable || false ? (
|
||||
<div
|
||||
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>
|
||||
) : undefined}
|
||||
|
||||
<Link
|
||||
href={
|
||||
`${
|
||||
format === ArchivedFormat.readability
|
||||
? `/preserved/${link?.id}?format=${format}`
|
||||
: `/api/v1/archives/${link?.id}?format=${
|
||||
link.screenshotPath?.endsWith("png")
|
||||
? ArchivedFormat.png
|
||||
: ArchivedFormat.jpeg
|
||||
}`
|
||||
}`
|
||||
}
|
||||
href={`${isPublic ? "/public" : ""}/preserved/${
|
||||
link?.id
|
||||
}?format=${format}`}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -4,6 +4,8 @@ import isValidUrl from "@/lib/shared/isValidUrl";
|
|||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import { TagIncludingLinkCount } from "@/types/global";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import PreservedFormatsModal from "../ModalContent/PreservedFormatsModal";
|
||||
|
||||
interface LinksIncludingTags extends LinkType {
|
||||
tags: TagIncludingLinkCount[];
|
||||
|
@ -25,6 +27,8 @@ export default function LinkCard({ link, count }: Props) {
|
|||
day: "numeric",
|
||||
});
|
||||
|
||||
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
|
||||
|
||||
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="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">
|
||||
{unescapeString(link.description)}{" "}
|
||||
<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"
|
||||
>
|
||||
<p>Read</p>
|
||||
<p>Visit</p>
|
||||
<i className={"bi-chevron-right"}></i>
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -31,9 +31,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
>
|
||||
<i
|
||||
className="bi-person text-primary text-2xl"
|
||||
></i>
|
||||
<i className="bi-person text-primary text-2xl"></i>
|
||||
|
||||
<p className="truncate w-full pr-7">Account</p>
|
||||
</div>
|
||||
|
@ -47,9 +45,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
>
|
||||
<i
|
||||
className="bi-palette text-primary text-2xl"
|
||||
></i>
|
||||
<i className="bi-palette text-primary text-2xl"></i>
|
||||
|
||||
<p className="truncate w-full pr-7">Appearance</p>
|
||||
</div>
|
||||
|
@ -63,9 +59,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
>
|
||||
<i
|
||||
className="bi-archive text-primary text-2xl"
|
||||
></i>
|
||||
<i className="bi-archive text-primary text-2xl"></i>
|
||||
<p className="truncate w-full pr-7">Archive</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
@ -78,9 +72,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
>
|
||||
<i
|
||||
className="bi-key text-primary text-2xl"
|
||||
></i>
|
||||
<i className="bi-key text-primary text-2xl"></i>
|
||||
<p className="truncate w-full pr-7">API Keys</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
@ -93,9 +85,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
>
|
||||
<i
|
||||
className="bi-lock text-primary text-2xl"
|
||||
></i>
|
||||
<i className="bi-lock text-primary text-2xl"></i>
|
||||
<p className="truncate w-full pr-7">Password</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
@ -109,9 +99,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
>
|
||||
<i
|
||||
className="bi-credit-card text-primary text-xl"
|
||||
></i>
|
||||
<i className="bi-credit-card text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">Billing</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
@ -130,9 +118,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
<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`}
|
||||
>
|
||||
<i
|
||||
className="bi-question-circle text-primary text-xl"
|
||||
></i>
|
||||
<i className="bi-question-circle text-primary text-xl"></i>
|
||||
|
||||
<p className="truncate w-full pr-7">Help</p>
|
||||
</div>
|
||||
|
@ -142,9 +128,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
<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`}
|
||||
>
|
||||
<i
|
||||
className="bi-github text-primary text-xl"
|
||||
></i>
|
||||
<i className="bi-github text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">GitHub</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
@ -153,9 +137,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
<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`}
|
||||
>
|
||||
<i
|
||||
className="bi-twitter-x text-primary text-xl"
|
||||
></i>
|
||||
<i className="bi-twitter-x text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">Twitter</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
@ -164,9 +146,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
<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`}
|
||||
>
|
||||
<i
|
||||
className="bi-mastodon text-primary text-xl"
|
||||
></i>
|
||||
<i className="bi-mastodon text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">Mastodon</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
@ -44,7 +44,7 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
return (
|
||||
<div
|
||||
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 || ""
|
||||
}`}
|
||||
>
|
||||
|
@ -70,7 +70,7 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
<SidebarHighlightLink
|
||||
title={"All Collections"}
|
||||
href={`/collections`}
|
||||
icon={"bi-folder2"}
|
||||
icon={"bi-folder"}
|
||||
active={active === `/collections`}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -28,9 +28,7 @@ export default function SidebarHighlightLink({
|
|||
<i className={`${icon} text-primary text-2xl drop-shadow`}></i>
|
||||
</div>
|
||||
<div className={"mt-1"}>
|
||||
<p className="truncate w-full text-xs font-semibold xl:text-sm">
|
||||
{title}
|
||||
</p>
|
||||
<p className="truncate w-full font-semibold text-sm">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
@ -2,8 +2,6 @@ import Navbar from "@/components/Navbar";
|
|||
import AnnouncementBar from "@/components/AnnouncementBar";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import ModalManagement from "@/components/ModalManagement";
|
||||
import useModalStore from "@/store/modals";
|
||||
import getLatestVersion from "@/lib/client/getLatestVersion";
|
||||
|
||||
interface Props {
|
||||
|
@ -11,14 +9,6 @@ interface 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 [showAnnouncement, setShowAnnouncement] = useState(
|
||||
showAnnouncementBar ? showAnnouncementBar === "true" : true
|
||||
|
@ -44,8 +34,6 @@ export default function MainLayout({ children }: Props) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ModalManagement />
|
||||
|
||||
{showAnnouncement ? (
|
||||
<AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} />
|
||||
) : undefined}
|
||||
|
@ -60,7 +48,7 @@ export default function MainLayout({ children }: Props) {
|
|||
<div
|
||||
className={`w-full flex flex-col min-h-${
|
||||
showAnnouncement ? "full" : "screen"
|
||||
} lg:ml-64 xl:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
|
||||
} lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
|
||||
>
|
||||
<Navbar />
|
||||
{children}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import SettingsSidebar from "@/components/SettingsSidebar";
|
||||
import React, { 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 Link from "next/link";
|
||||
|
@ -12,16 +10,8 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function SettingsLayout({ 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();
|
||||
|
@ -40,8 +30,6 @@ export default function SettingsLayout({ children }: Props) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ModalManagement />
|
||||
|
||||
<div className="flex max-w-screen-md mx-auto">
|
||||
<div className="hidden lg:block fixed h-screen">
|
||||
<SettingsSidebar />
|
||||
|
|
|
@ -7,6 +7,7 @@ import { JSDOM } from "jsdom";
|
|||
import DOMPurify from "dompurify";
|
||||
import { Collection, Link, User } from "@prisma/client";
|
||||
import validateUrlSize from "./validateUrlSize";
|
||||
import removeFile from "./storage/removeFile";
|
||||
|
||||
type LinksAndCollectionAndOwner = Link & {
|
||||
collection: Collection & {
|
||||
|
@ -45,9 +46,18 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||
where: { id: link.id },
|
||||
data: {
|
||||
type: linkType,
|
||||
screenshotPath: user.archiveAsScreenshot ? "pending" : undefined,
|
||||
pdfPath: user.archiveAsPDF ? "pending" : undefined,
|
||||
readabilityPath: "pending",
|
||||
screenshotPath:
|
||||
user.archiveAsScreenshot &&
|
||||
!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(),
|
||||
},
|
||||
});
|
||||
|
@ -65,7 +75,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||
|
||||
const content = await page.content();
|
||||
|
||||
// TODO Webarchive
|
||||
// TODO single file
|
||||
// const session = await page.context().newCDPSession(page);
|
||||
// const doc = await session.send("Page.captureSnapshot", {
|
||||
// format: "mhtml",
|
||||
|
@ -180,6 +190,13 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||
: 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();
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import getPermission from "@/lib/api/getPermission";
|
|||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import validateUrlSize from "../../validateUrlSize";
|
||||
|
||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||
|
||||
export default async function postLink(
|
||||
link: LinkIncludingShortenedCollectionAndTags,
|
||||
userId: number
|
||||
|
@ -24,6 +26,20 @@ export default async function postLink(
|
|||
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();
|
||||
|
||||
if (link.collection.id) {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import { Backup } from "@/types/global";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||
|
||||
export default async function importFromHTMLFile(
|
||||
userId: number,
|
||||
rawData: string
|
||||
|
@ -10,6 +11,23 @@ export default async function importFromHTMLFile(
|
|||
const dom = new JSDOM(rawData);
|
||||
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");
|
||||
|
||||
await prisma
|
||||
|
|
|
@ -2,9 +2,34 @@ import { prisma } from "@/lib/api/db";
|
|||
import { Backup } from "@/types/global";
|
||||
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);
|
||||
|
||||
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
|
||||
.$transaction(
|
||||
async () => {
|
||||
|
|
|
@ -74,6 +74,8 @@ export default async function getPublicUser(
|
|||
name: lessSensitiveInfo.name,
|
||||
username: lessSensitiveInfo.username,
|
||||
image: lessSensitiveInfo.image,
|
||||
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
|
||||
archiveAsPDF: lessSensitiveInfo.archiveAsPDF,
|
||||
};
|
||||
|
||||
return { response: data, status: 200 };
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import archiveHandler from "@/lib/api/archiveHandler";
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import verifyUser from "@/lib/api/verifyUser";
|
||||
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;
|
||||
|
||||
|
@ -42,20 +43,17 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
|||
} minutes or create a new one.`,
|
||||
});
|
||||
|
||||
if (link.url && isValidUrl(link.url)) {
|
||||
archiveHandler(link);
|
||||
if (!link.url || !isValidUrl(link.url))
|
||||
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({
|
||||
response: "Link is being archived.",
|
||||
});
|
||||
}
|
||||
|
||||
// TODO - Later?
|
||||
// else if (req.method === "DELETE") {}
|
||||
}
|
||||
|
||||
const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
|
||||
|
@ -68,3 +66,26 @@ const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
|
|||
|
||||
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`,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -55,6 +55,8 @@ export default function Index() {
|
|||
name: "",
|
||||
username: "",
|
||||
image: "",
|
||||
archiveAsScreenshot: undefined as unknown as boolean,
|
||||
archiveAsPDF: undefined as unknown as boolean,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -70,6 +72,8 @@ export default function Index() {
|
|||
name: account.name,
|
||||
username: account.username 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>
|
||||
</div>
|
||||
|
||||
<div className="dropdown dropdown-bottom dropdown-end">
|
||||
<div className="dropdown dropdown-bottom dropdown-end mt-2">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
|
|
|
@ -56,19 +56,11 @@ export default function Collections() {
|
|||
|
||||
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 my-5">
|
||||
<i className="bi-folder text-3xl sm:text-2xl text-primary drop-shadow"></i>
|
||||
|
||||
<div>
|
||||
<p className="text-3xl capitalize font-thin">
|
||||
Other Collections
|
||||
</p>
|
||||
|
||||
<p className="sm:text-sm text-xs">
|
||||
Shared collections you're a member of
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={"Other Collections"}
|
||||
description={"Shared collections you're a member of"}
|
||||
/>
|
||||
|
||||
<div className="grid xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
{sortedCollections
|
||||
|
|
|
@ -8,7 +8,6 @@ import useLinks from "@/hooks/useLinks";
|
|||
import Link from "next/link";
|
||||
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||
import React from "react";
|
||||
import useModalStore from "@/store/modals";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { MigrationFormat, MigrationRequest } from "@/types/global";
|
||||
import DashboardItem from "@/components/DashboardItem";
|
||||
|
@ -20,8 +19,6 @@ export default function Dashboard() {
|
|||
const { links } = useLinkStore();
|
||||
const { tags } = useTagStore();
|
||||
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
|
||||
const [showLinks, setShowLinks] = useState(3);
|
||||
|
@ -100,14 +97,14 @@ export default function Dashboard() {
|
|||
description={"A brief overview of your data"}
|
||||
/>
|
||||
<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
|
||||
name={numberOfLinks === 1 ? "Link" : "Links"}
|
||||
value={numberOfLinks}
|
||||
icon={"bi-link-45deg"}
|
||||
/>
|
||||
|
||||
<div className="divider md:divider-horizontal"></div>
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
|
||||
<DashboardItem
|
||||
name={collections.length === 1 ? "Collection" : "Collections"}
|
||||
|
@ -115,7 +112,7 @@ export default function Dashboard() {
|
|||
icon={"bi-folder"}
|
||||
/>
|
||||
|
||||
<div className="divider md:divider-horizontal"></div>
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
|
||||
<DashboardItem
|
||||
name={tags.length === 1 ? "Tag" : "Tags"}
|
||||
|
@ -127,15 +124,18 @@ export default function Dashboard() {
|
|||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<i className="bi-clock-history text-primary text-2xl drop-shadow"></i>
|
||||
<p className="text-2xl">Recent</p>
|
||||
<PageHeader
|
||||
icon={"bi-clock-history"}
|
||||
title={"Recent"}
|
||||
description={"Recently added Links"}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/links"
|
||||
className="flex items-center text-sm text-black/75 dark:text-white/75 gap-2 cursor-pointer"
|
||||
>
|
||||
View All
|
||||
<i className="bi-chevron-right text-sm "></i>
|
||||
<i className="bi-chevron-right text-sm"></i>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
@ -171,7 +171,7 @@ export default function Dashboard() {
|
|||
onClick={() => {
|
||||
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>
|
||||
<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 gap-2 items-center">
|
||||
<i className="bi-pin-angle text-primary text-2xl drop-shadow"></i>
|
||||
<p className="text-2xl">Pinned</p>
|
||||
<PageHeader
|
||||
icon={"bi-pin-angle"}
|
||||
title={"Pinned"}
|
||||
description={"Your pinned Links"}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/links/pinned"
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { useRouter } from "next/router";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import ReadableView from "@/components/ReadableView";
|
||||
|
||||
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">
|
||||
Readable
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import Head from "next/head";
|
|||
import useLinks from "@/hooks/useLinks";
|
||||
import useLinkStore from "@/store/links";
|
||||
import ProfilePhoto from "@/components/ProfilePhoto";
|
||||
import ModalManagement from "@/components/ModalManagement";
|
||||
import ToggleDarkMode from "@/components/ToggleDarkMode";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
import Image from "next/image";
|
||||
|
@ -42,10 +41,12 @@ export default function PublicCollections() {
|
|||
const router = useRouter();
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState({
|
||||
id: null,
|
||||
id: null as unknown as number,
|
||||
name: "",
|
||||
username: "",
|
||||
image: "",
|
||||
archiveAsScreenshot: undefined as unknown as boolean,
|
||||
archiveAsPDF: undefined as unknown as boolean,
|
||||
});
|
||||
|
||||
const [searchFilter, setSearchFilter] = useState({
|
||||
|
@ -95,15 +96,13 @@ export default function PublicCollections() {
|
|||
|
||||
return collection ? (
|
||||
<div
|
||||
className="h-screen"
|
||||
className="h-96"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
|
||||
settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 18rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
<ModalManagement />
|
||||
|
||||
{collection ? (
|
||||
<Head>
|
||||
<title>{collection.name} | Linkwarden</title>
|
||||
|
@ -209,6 +208,11 @@ export default function PublicCollections() {
|
|||
{links
|
||||
?.filter((e) => e.collectionId === Number(router.query.id))
|
||||
.map((e, i) => {
|
||||
const linkWithCollectionData = {
|
||||
...e,
|
||||
collection: collection, // Append collection data
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
|
@ -217,7 +221,10 @@ export default function PublicCollections() {
|
|||
viewport={{ once: true, amount: 0.8 }}
|
||||
>
|
||||
<motion.div variants={cardVariants}>
|
||||
<PublicLinkCard link={e as any} count={i} />
|
||||
<PublicLinkCard
|
||||
link={linkWithCollectionData as any}
|
||||
count={i}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -10,7 +10,6 @@ import SubmitButton from "@/components/SubmitButton";
|
|||
import React from "react";
|
||||
import { MigrationFormat, MigrationRequest } from "@/types/global";
|
||||
import Link from "next/link";
|
||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||
import Checkbox from "@/components/Checkbox";
|
||||
|
||||
export default function Account() {
|
||||
|
@ -85,9 +84,9 @@ export default function Account() {
|
|||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
const [importDropdown, setImportDropdown] = useState(false);
|
||||
|
||||
const importBookmarks = async (e: any, format: MigrationFormat) => {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const file: File = e.target.files[0];
|
||||
|
||||
if (file) {
|
||||
|
@ -112,18 +111,19 @@ export default function Account() {
|
|||
|
||||
toast.dismiss(load);
|
||||
|
||||
toast.success("Imported the Bookmarks! Reloading the page...");
|
||||
|
||||
setImportDropdown(false);
|
||||
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 2000);
|
||||
if (response.ok) {
|
||||
toast.success("Imported the Bookmarks! Reloading the page...");
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 2000);
|
||||
} else toast.error(data.response as string);
|
||||
};
|
||||
reader.onerror = function (e) {
|
||||
console.log("Error:", e);
|
||||
};
|
||||
}
|
||||
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState("");
|
||||
|
|
|
@ -26,7 +26,7 @@ const s3Client =
|
|||
async function checkFileExistence(path) {
|
||||
if (s3Client) {
|
||||
const bucketParams = {
|
||||
Bucket: process.env.BUCKET_NAME,
|
||||
Bucket: process.env.SPACES_BUCKET_NAME,
|
||||
Key: path,
|
||||
};
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ declare global {
|
|||
AUTOSCROLL_TIMEOUT?: string;
|
||||
RE_ARCHIVE_LIMIT?: string;
|
||||
NEXT_PUBLIC_MAX_FILE_SIZE?: string;
|
||||
MAX_LINKS_PER_USER?: string;
|
||||
|
||||
SPACES_KEY?: string;
|
||||
SPACES_SECRET?: string;
|
||||
|
|
Ŝarĝante…
Reference in New Issue