diff --git a/components/InputSelect/CollectionSelection.tsx b/components/InputSelect/CollectionSelection.tsx index 71dc075..ec586ea 100644 --- a/components/InputSelect/CollectionSelection.tsx +++ b/components/InputSelect/CollectionSelection.tsx @@ -4,18 +4,26 @@ import { useEffect, useState } from "react"; import { styles } from "./styles"; import { Options } from "./types"; import CreatableSelect from "react-select/creatable"; +import Select from "react-select"; type Props = { onChange: any; - defaultValue: + showDefaultValue?: boolean; + defaultValue?: | { label: string; value?: number; } | undefined; + creatable?: boolean; }; -export default function CollectionSelection({ onChange, defaultValue }: Props) { +export default function CollectionSelection({ + onChange, + defaultValue, + showDefaultValue = true, + creatable = true, +}: Props) { const { collections } = useCollectionStore(); const router = useRouter(); @@ -42,16 +50,31 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) { setOptions(formatedCollections); }, [collections]); - return ( - - ); + if (creatable) { + return ( + + ); + } else { + return ( + selectedLink.id === link.id + )} + onChange={() => handleCheckboxClick(link)} + /> + )} */} + {!editMode ? ( + <> + +
+
- - - - setShowInfo(!showInfo)} - // linkInfo={showInfo} - /> - {showInfo ? ( -
-
-

Description

+
+

+ {unescapeString(link.name || link.description) || link.url} +

-
-

- {link.description ? ( - unescapeString(link.description) - ) : ( - - No description provided. - - )} -

- {link.tags[0] ? ( - <> -

- Tags +

+
+ {collection && ( + + )} + {link.url ? ( +
+ +

{shortendURL}

+
+ ) : ( +
+ {link.type} +
+ )} + +
+
+
+ + setShowInfo(!showInfo)} + // linkInfo={showInfo} + /> + {showInfo && ( +
+
+

+ Description


+

+ {link.description ? ( + unescapeString(link.description) + ) : ( + + No description provided. + + )} +

+ {link.tags[0] && ( + <> +

+ Tags +

-
-
- {link.tags.map((e, i) => ( - { - e.stopPropagation(); - }} - className="btn btn-xs btn-ghost truncate max-w-[19rem]" - > - #{e.name} - - ))} -
+
+ +
+
+ {link.tags.map((e, i) => ( + { + e.stopPropagation(); + }} + className="btn btn-xs btn-ghost truncate max-w-[19rem]" + > + #{e.name} + + ))} +
+
+ + )} +
+
+ )} + + ) : ( + <> +
+
+ +
+ +
+

+ {unescapeString(link.name || link.description) || link.url} +

+ +
+
+ {collection ? ( + + ) : undefined} + {link.url ? ( +
+ +

+ {shortendURL} +

+
+ ) : ( +
+ {link.type} +
+ )} +
- - ) : undefined} +
+
-
- ) : undefined} + setShowInfo(!showInfo)} + // linkInfo={showInfo} + /> + + )}
-
); diff --git a/components/ModalContent/BulkDeleteLinksModal.tsx b/components/ModalContent/BulkDeleteLinksModal.tsx new file mode 100644 index 0000000..6de26cd --- /dev/null +++ b/components/ModalContent/BulkDeleteLinksModal.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import useLinkStore from "@/store/links"; +import toast from "react-hot-toast"; +import Modal from "../Modal"; + +type Props = { + onClose: Function; +}; + +export default function BulkDeleteLinksModal({ onClose }: Props) { + const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore(); + + const deleteLink = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + if (response.ok) { + toast.success( + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }` + ); + + setSelectedLinks([]); + onClose(); + } else toast.error(response.data as string); + }; + + return ( + +

+ Delete {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""} +

+ +
+ +
+ {selectedLinks.length > 1 ? ( +

Are you sure you want to delete {selectedLinks.length} links?

+ ) : ( +

Are you sure you want to delete this link?

+ )} + +
+ + + Warning: This action is irreversible! + +
+ +

+ Hold the Shift key while clicking + 'Delete' to bypass this confirmation in the future. +

+ + +
+
+ ); +} diff --git a/components/ModalContent/BulkEditLinksModal.tsx b/components/ModalContent/BulkEditLinksModal.tsx new file mode 100644 index 0000000..07b3914 --- /dev/null +++ b/components/ModalContent/BulkEditLinksModal.tsx @@ -0,0 +1,102 @@ +import React, { useState } from "react"; +import CollectionSelection from "@/components/InputSelect/CollectionSelection"; +import TagSelection from "@/components/InputSelect/TagSelection"; +import useLinkStore from "@/store/links"; +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import toast from "react-hot-toast"; +import Modal from "../Modal"; + +type Props = { + onClose: Function; +}; + +export default function BulkEditLinksModal({ onClose }: Props) { + const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore(); + const [submitLoader, setSubmitLoader] = useState(false); + const [removePreviousTags, setRemovePreviousTags] = useState(false); + const [updatedValues, setUpdatedValues] = useState< + Pick + >({ tags: [] }); + + const setCollection = (e: any) => { + const collectionId = e?.value || null; + console.log(updatedValues); + setUpdatedValues((prevValues) => ({ ...prevValues, collectionId })); + }; + + const setTags = (e: any) => { + const tags = e.map((tag: any) => ({ name: tag.label })); + setUpdatedValues((prevValues) => ({ ...prevValues, tags })); + }; + + const submit = async () => { + if (!submitLoader) { + setSubmitLoader(true); + + const load = toast.loading("Updating..."); + + const response = await updateLinks( + selectedLinks, + removePreviousTags, + updatedValues + ); + + toast.dismiss(load); + + if (response.ok) { + toast.success(`Updated!`); + setSelectedLinks([]); + onClose(); + } else toast.error(response.data as string); + + setSubmitLoader(false); + return response; + } + }; + + return ( + +

+ Edit {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""} +

+
+
+
+
+

Move to Collection

+ +
+ +
+

Add Tags

+ +
+
+
+ +
+
+ +
+ +
+
+ ); +} diff --git a/components/ModalContent/DeleteLinkModal.tsx b/components/ModalContent/DeleteLinkModal.tsx index a2b123c..1a3a476 100644 --- a/components/ModalContent/DeleteLinkModal.tsx +++ b/components/ModalContent/DeleteLinkModal.tsx @@ -15,7 +15,6 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) { useState(activeLink); const { removeLink } = useLinkStore(); - const [submitLoader, setSubmitLoader] = useState(false); const router = useRouter(); diff --git a/components/ModalContent/EditCollectionSharingModal.tsx b/components/ModalContent/EditCollectionSharingModal.tsx index f54a3ed..9a73d5b 100644 --- a/components/ModalContent/EditCollectionSharingModal.tsx +++ b/components/ModalContent/EditCollectionSharingModal.tsx @@ -234,11 +234,8 @@ export default function EditCollectionSharingModal({ : undefined; return ( - <> -
+ +
@@ -433,7 +430,7 @@ export default function EditCollectionSharingModal({
- +
); })}
diff --git a/components/ModalContent/EditLinkModal.tsx b/components/ModalContent/EditLinkModal.tsx index 9ba27fe..b77b76c 100644 --- a/components/ModalContent/EditLinkModal.tsx +++ b/components/ModalContent/EditLinkModal.tsx @@ -124,6 +124,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) { label: "Unorganized", } } + creatable={false} /> ) : null}
diff --git a/components/SortDropdown.tsx b/components/SortDropdown.tsx index 2f42e3a..81d4496 100644 --- a/components/SortDropdown.tsx +++ b/components/SortDropdown.tsx @@ -14,7 +14,7 @@ export default function SortDropdown({ sortBy, setSort }: Props) { tabIndex={0} role="button" onMouseDown={dropdownTriggerer} - className="btn btn-sm btn-square btn-ghost" + className="btn btn-sm btn-square btn-ghost border-none" > diff --git a/components/ViewDropdown.tsx b/components/ViewDropdown.tsx index 23558d2..5ab821e 100644 --- a/components/ViewDropdown.tsx +++ b/components/ViewDropdown.tsx @@ -1,4 +1,4 @@ -import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; +import React, { Dispatch, SetStateAction, useEffect } from "react"; import useLocalSettingsStore from "@/store/localSettings"; import { ViewMode } from "@/types/global"; diff --git a/hooks/useCollectivePermissions.ts b/hooks/useCollectivePermissions.ts new file mode 100644 index 0000000..b1e3b7c --- /dev/null +++ b/hooks/useCollectivePermissions.ts @@ -0,0 +1,34 @@ +import useAccountStore from "@/store/account"; +import useCollectionStore from "@/store/collections"; +import { Member } from "@/types/global"; +import { useEffect, useState } from "react"; + +export default function useCollectivePermissions(collectionIds: number[]) { + const { collections } = useCollectionStore(); + + const { account } = useAccountStore(); + + const [permissions, setPermissions] = useState(); + useEffect(() => { + for (const collectionId of collectionIds) { + const collection = collections.find((e) => e.id === collectionId); + + if (collection) { + let getPermission: Member | undefined = collection.members.find( + (e) => e.userId === account.id + ); + + if ( + getPermission?.canCreate === false && + getPermission?.canUpdate === false && + getPermission?.canDelete === false + ) + getPermission = undefined; + + setPermissions(account.id === collection.ownerId || getPermission); + } + } + }, [account, collections, collectionIds]); + + return permissions; +} diff --git a/hooks/useLinks.tsx b/hooks/useLinks.tsx index 77e4dce..6a06c1b 100644 --- a/hooks/useLinks.tsx +++ b/hooks/useLinks.tsx @@ -18,7 +18,8 @@ export default function useLinks( searchByTextContent, }: LinkRequestQuery = { sort: 0 } ) { - const { links, setLinks, resetLinks } = useLinkStore(); + const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } = + useLinkStore(); const router = useRouter(); const { reachedBottom, setReachedBottom } = useDetectPageBottom(); @@ -68,8 +69,12 @@ export default function useLinks( }; useEffect(() => { + // Save the selected links before resetting the links + // and then restore the selected links after resetting the links + const previouslySelected = selectedLinks; resetLinks(); + setSelectedLinks(previouslySelected); getLinks(true); }, [ router, diff --git a/lib/api/archiveHandler.ts b/lib/api/archiveHandler.ts index 88ff97e..c43b6db 100644 --- a/lib/api/archiveHandler.ts +++ b/lib/api/archiveHandler.ts @@ -23,8 +23,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) { const browser = await chromium.launch(); const context = await browser.newContext({ ...devices["Desktop Chrome"], - ignoreHTTPSErrors: - process.env.IGNORE_HTTPS_ERRORS === "true", + ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === "true", }); const page = await context.newPage(); diff --git a/lib/api/controllers/links/bulk/deleteLinksById.ts b/lib/api/controllers/links/bulk/deleteLinksById.ts new file mode 100644 index 0000000..466db98 --- /dev/null +++ b/lib/api/controllers/links/bulk/deleteLinksById.ts @@ -0,0 +1,58 @@ +import { prisma } from "@/lib/api/db"; +import { UsersAndCollections } from "@prisma/client"; +import getPermission from "@/lib/api/getPermission"; +import removeFile from "@/lib/api/storage/removeFile"; + +export default async function deleteLinksById( + userId: number, + linkIds: number[] +) { + if (!linkIds || linkIds.length === 0) { + return { response: "Please choose valid links.", status: 401 }; + } + + const collectionIsAccessibleArray = []; + + // Check if the user has access to the collection of each link + // if any of the links are not accessible, return an error + // if all links are accessible, continue with the deletion + // and add the collection to the collectionIsAccessibleArray + for (const linkId of linkIds) { + const collectionIsAccessible = await getPermission({ userId, linkId }); + + const memberHasAccess = collectionIsAccessible?.members.some( + (e: UsersAndCollections) => e.userId === userId && e.canDelete + ); + + if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess)) { + return { response: "Collection is not accessible.", status: 401 }; + } + + collectionIsAccessibleArray.push(collectionIsAccessible); + } + + const deletedLinks = await prisma.link.deleteMany({ + where: { + id: { in: linkIds }, + }, + }); + + // Loop through each link and delete the associated files + // if the user has access to the collection + for (let i = 0; i < linkIds.length; i++) { + const linkId = linkIds[i]; + const collectionIsAccessible = collectionIsAccessibleArray[i]; + + removeFile({ + filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`, + }); + removeFile({ + filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`, + }); + removeFile({ + filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`, + }); + } + + return { response: deletedLinks, status: 200 }; +} diff --git a/lib/api/controllers/links/bulk/updateLinks.ts b/lib/api/controllers/links/bulk/updateLinks.ts new file mode 100644 index 0000000..a214c30 --- /dev/null +++ b/lib/api/controllers/links/bulk/updateLinks.ts @@ -0,0 +1,50 @@ +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import updateLinkById from "../linkId/updateLinkById"; + +export default async function updateLinks( + userId: number, + links: LinkIncludingShortenedCollectionAndTags[], + removePreviousTags: boolean, + newData: Pick< + LinkIncludingShortenedCollectionAndTags, + "tags" | "collectionId" + > +) { + let allUpdatesSuccessful = true; + + // Have to use a loop here rather than updateMany, see the following: + // https://github.com/prisma/prisma/issues/3143 + for (const link of links) { + let updatedTags = [...link.tags, ...(newData.tags ?? [])]; + + if (removePreviousTags) { + // If removePreviousTags is true, replace the existing tags with new tags + updatedTags = [...(newData.tags ?? [])]; + } + + const updatedData: LinkIncludingShortenedCollectionAndTags = { + ...link, + tags: updatedTags, + collection: { + ...link.collection, + id: newData.collectionId ?? link.collection.id, + }, + }; + + const updatedLink = await updateLinkById( + userId, + link.id as number, + updatedData + ); + + if (updatedLink.status !== 200) { + allUpdatesSuccessful = false; + } + } + + if (allUpdatesSuccessful) { + return { response: "All links updated successfully", status: 200 }; + } else { + return { response: "Some links failed to update", status: 400 }; + } +} diff --git a/lib/api/controllers/links/linkId/deleteLinkById.ts b/lib/api/controllers/links/linkId/deleteLinkById.ts index 90adba4..db68ee7 100644 --- a/lib/api/controllers/links/linkId/deleteLinkById.ts +++ b/lib/api/controllers/links/linkId/deleteLinkById.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/api/db"; -import { Collection, Link, UsersAndCollections } from "@prisma/client"; +import { Link, UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; import removeFile from "@/lib/api/storage/removeFile"; diff --git a/lib/api/controllers/links/linkId/updateLinkById.ts b/lib/api/controllers/links/linkId/updateLinkById.ts index ce6b920..62b2945 100644 --- a/lib/api/controllers/links/linkId/updateLinkById.ts +++ b/lib/api/controllers/links/linkId/updateLinkById.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/api/db"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; -import { Collection, Link, UsersAndCollections } from "@prisma/client"; +import { UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; import moveFile from "@/lib/api/storage/moveFile"; @@ -16,6 +16,10 @@ export default async function updateLinkById( }; const collectionIsAccessible = await getPermission({ userId, linkId }); + const targetCollectionIsAccessible = await getPermission({ + userId, + collectionId: data.collection.id, + }); const memberHasAccess = collectionIsAccessible?.members.some( (e: UsersAndCollections) => e.userId === userId && e.canUpdate @@ -25,6 +29,28 @@ export default async function updateLinkById( collectionIsAccessible?.ownerId === data.collection.ownerId && data.collection.ownerId === userId; + const targetCollectionsAccessible = + targetCollectionIsAccessible?.ownerId === userId; + + const targetCollectionMatchesData = data.collection.id + ? data.collection.id === targetCollectionIsAccessible?.id + : true && data.collection.name + ? data.collection.name === targetCollectionIsAccessible?.name + : true && data.collection.ownerId + ? data.collection.ownerId === targetCollectionIsAccessible?.ownerId + : true; + + if (!targetCollectionsAccessible) + return { + response: "Target collection is not accessible.", + status: 401, + }; + else if (!targetCollectionMatchesData) + return { + response: "Target collection does not match the data.", + status: 401, + }; + const unauthorizedSwitchCollection = !isCollectionOwner && collectionIsAccessible?.id !== data.collection.id; diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index 162f246..6bb7480 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -97,18 +97,18 @@ export default async function updateUserById( id: { not: userId }, OR: emailEnabled ? [ - { - username: data.username.toLowerCase(), - }, - { - email: data.email?.toLowerCase(), - }, - ] + { + username: data.username.toLowerCase(), + }, + { + email: data.email?.toLowerCase(), + }, + ] : [ - { - username: data.username.toLowerCase(), - }, - ], + { + username: data.username.toLowerCase(), + }, + ], }, }); diff --git a/lib/api/getPermission.ts b/lib/api/getPermission.ts index 61dc5c5..93dd04c 100644 --- a/lib/api/getPermission.ts +++ b/lib/api/getPermission.ts @@ -3,12 +3,14 @@ import { prisma } from "@/lib/api/db"; type Props = { userId: number; collectionId?: number; + collectionName?: string; linkId?: number; }; export default async function getPermission({ userId, collectionId, + collectionName, linkId, }: Props) { if (linkId) { @@ -24,10 +26,11 @@ export default async function getPermission({ }); return check; - } else if (collectionId) { + } else if (collectionId || collectionName) { const check = await prisma.collection.findFirst({ where: { - id: collectionId, + id: collectionId || undefined, + name: collectionName || undefined, OR: [{ ownerId: userId }, { members: { some: { userId } } }], }, include: { members: true }, diff --git a/lib/client/generateLinkHref.ts b/lib/client/generateLinkHref.ts index 5911ae7..47c1888 100644 --- a/lib/client/generateLinkHref.ts +++ b/lib/client/generateLinkHref.ts @@ -1,27 +1,39 @@ -import { AccountSettings, ArchivedFormat, LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import { + AccountSettings, + ArchivedFormat, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; import { LinksRouteTo } from "@prisma/client"; -import { pdfAvailable, readabilityAvailable, screenshotAvailable } from "../shared/getArchiveValidity"; +import { + pdfAvailable, + readabilityAvailable, + screenshotAvailable, +} from "../shared/getArchiveValidity"; -export const generateLinkHref = (link: LinkIncludingShortenedCollectionAndTags, account: AccountSettings): string => { +export const generateLinkHref = ( + link: LinkIncludingShortenedCollectionAndTags, + account: AccountSettings +): string => { + // Return the links href based on the account's preference + // If the user's preference is not available, return the original link + switch (account.linksRouteTo) { + case LinksRouteTo.ORIGINAL: + return link.url || ""; + case LinksRouteTo.PDF: + if (!pdfAvailable(link)) return link.url || ""; - // Return the links href based on the account's preference - // If the user's preference is not available, return the original link - switch (account.linksRouteTo) { - case LinksRouteTo.ORIGINAL: - return link.url || ''; - case LinksRouteTo.PDF: - if (!pdfAvailable(link)) return link.url || ''; + return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`; + case LinksRouteTo.READABLE: + if (!readabilityAvailable(link)) return link.url || ""; - return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`; - case LinksRouteTo.READABLE: - if (!readabilityAvailable(link)) return link.url || ''; + return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`; + case LinksRouteTo.SCREENSHOT: + if (!screenshotAvailable(link)) return link.url || ""; - return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`; - case LinksRouteTo.SCREENSHOT: - if (!screenshotAvailable(link)) return link.url || ''; - - return `/preserved/${link?.id}?format=${link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg}`; - default: - return link.url || ''; - } -}; \ No newline at end of file + return `/preserved/${link?.id}?format=${ + link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg + }`; + default: + return link.url || ""; + } +}; diff --git a/lib/shared/getArchiveValidity.ts b/lib/shared/getArchiveValidity.ts index ec74b22..0da5504 100644 --- a/lib/shared/getArchiveValidity.ts +++ b/lib/shared/getArchiveValidity.ts @@ -1,6 +1,8 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; -export function screenshotAvailable(link: LinkIncludingShortenedCollectionAndTags) { +export function screenshotAvailable( + link: LinkIncludingShortenedCollectionAndTags +) { return ( link && link.image && @@ -15,7 +17,9 @@ export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) { ); } -export function readabilityAvailable(link: LinkIncludingShortenedCollectionAndTags) { +export function readabilityAvailable( + link: LinkIncludingShortenedCollectionAndTags +) { return ( link && link.readable && diff --git a/pages/api/v1/links/index.ts b/pages/api/v1/links/index.ts index 58a352a..35b0243 100644 --- a/pages/api/v1/links/index.ts +++ b/pages/api/v1/links/index.ts @@ -3,6 +3,8 @@ import getLinks from "@/lib/api/controllers/links/getLinks"; import postLink from "@/lib/api/controllers/links/postLink"; import { LinkRequestQuery } from "@/types/global"; import verifyUser from "@/lib/api/verifyUser"; +import deleteLinksById from "@/lib/api/controllers/links/bulk/deleteLinksById"; +import updateLinks from "@/lib/api/controllers/links/bulk/updateLinks"; export default async function links(req: NextApiRequest, res: NextApiResponse) { const user = await verifyUser({ req, res }); @@ -39,5 +41,20 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) { return res.status(newlink.status).json({ response: newlink.response, }); + } else if (req.method === "PUT") { + const updated = await updateLinks( + user.id, + req.body.links, + req.body.removePreviousTags, + req.body.newData + ); + return res.status(updated.status).json({ + response: updated.response, + }); + } else if (req.method === "DELETE") { + const deleted = await deleteLinksById(user.id, req.body.linkIds); + return res.status(deleted.status).json({ + response: deleted.response, + }); } } diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index fd0f970..323e6e6 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -24,15 +24,18 @@ import CardView from "@/components/LinkViews/Layouts/CardView"; // import GridView from "@/components/LinkViews/Layouts/GridView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import { dropdownTriggerer } from "@/lib/client/utils"; -import Link from "next/link"; import NewCollectionModal from "@/components/ModalContent/NewCollectionModal"; +import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; +import toast from "react-hot-toast"; +import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; export default function Index() { const { settings } = useLocalSettingsStore(); const router = useRouter(); - const { links } = useLinkStore(); + const { links, selectedLinks, setSelectedLinks, deleteLinksById } = + useLinkStore(); const { collections } = useCollectionStore(); const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); @@ -81,6 +84,9 @@ export default function Index() { }; fetchOwner(); + + // When the collection changes, reset the selected links + setSelectedLinks([]); }, [activeCollection]); const [editCollectionModal, setEditCollectionModal] = useState(false); @@ -88,6 +94,14 @@ export default function Index() { const [editCollectionSharingModal, setEditCollectionSharingModal] = useState(false); const [deleteCollectionModal, setDeleteCollectionModal] = useState(false); + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + const [editMode, setEditMode] = useState(false); + useEffect(() => { + return () => { + setEditMode(false); + }; + }, [router]); const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card @@ -102,6 +116,35 @@ export default function Index() { // @ts-ignore const LinkComponent = linkView[viewMode]; + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }!` + ); + }; + return (
    - {permissions === true ? ( + {permissions === true && (
  • - ) : undefined} + )}
  • - {permissions === true ? ( + {permissions === true && (
  • - ) : undefined} + )}
  • )} - {activeCollection ? ( + {activeCollection && (

    By {collectionOwner.name} - {activeCollection.members.length > 0 - ? ` and ${activeCollection.members.length} others` - : undefined} + {activeCollection.members.length > 0 && + ` and ${activeCollection.members.length} others`} .

    - ) : undefined} + )} - {activeCollection?.description ? ( + {activeCollection?.description && (

    {activeCollection?.description}

    - ) : undefined} + )} {/* {collections.some((e) => e.parentId === activeCollection.id) ? (
    @@ -272,16 +314,88 @@ export default function Index() {
    -
    +

    Showing {activeCollection?._count?.links} results

    + {links.length > 0 && + (permissions === true || + permissions?.canUpdate || + permissions?.canDelete) && ( +
    { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
    + )}
    + {editMode && ( +
    + {links.length > 0 && ( +
    + handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length}{" "} + {selectedLinks.length === 1 ? "link" : "links"} selected + + ) : ( + Nothing selected + )} +
    + )} +
    + + +
    +
    + )} + {links.some((e) => e.collectionId === Number(router.query.id)) ? ( e.collection.id === activeCollection?.id )} @@ -290,34 +404,48 @@ export default function Index() { )}
    - {activeCollection ? ( + {activeCollection && ( <> - {editCollectionModal ? ( + {editCollectionModal && ( setEditCollectionModal(false)} activeCollection={activeCollection} /> - ) : undefined} - {editCollectionSharingModal ? ( + )} + {editCollectionSharingModal && ( setEditCollectionSharingModal(false)} activeCollection={activeCollection} /> - ) : undefined} - {newCollectionModal ? ( + )} + {newCollectionModal && ( setNewCollectionModal(false)} parent={activeCollection} /> - ) : undefined} - {deleteCollectionModal ? ( + )} + {deleteCollectionModal && ( setDeleteCollectionModal(false)} activeCollection={activeCollection} /> - ) : undefined} + )} + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} - ) : undefined} + )} ); } diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 6fabf33..ec5dc78 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -168,7 +168,10 @@ export default function Dashboard() { > {links[0] ? (
    - +
    ) : (
    e.pinnedBy && e.pinnedBy[0]) ? (
    e.pinnedBy && e.pinnedBy[0]) .slice(0, showLinks)} diff --git a/pages/links/index.tsx b/pages/links/index.tsx index 9169398..3d99a9c 100644 --- a/pages/links/index.tsx +++ b/pages/links/index.tsx @@ -3,24 +3,74 @@ import SortDropdown from "@/components/SortDropdown"; import useLinks from "@/hooks/useLinks"; import MainLayout from "@/layouts/MainLayout"; import useLinkStore from "@/store/links"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import PageHeader from "@/components/PageHeader"; -import { Sort, ViewMode } from "@/types/global"; +import { Member, Sort, ViewMode } from "@/types/global"; import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; +import useCollectivePermissions from "@/hooks/useCollectivePermissions"; +import toast from "react-hot-toast"; +import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; +import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; // import GridView from "@/components/LinkViews/Layouts/GridView"; +import { useRouter } from "next/router"; export default function Links() { - const { links } = useLinkStore(); + const { links, selectedLinks, deleteLinksById, setSelectedLinks } = + useLinkStore(); const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card ); const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); + const router = useRouter(); + + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + const [editMode, setEditMode] = useState(false); + useEffect(() => { + return () => { + setEditMode(false); + }; + }, [router]); + + const collectivePermissions = useCollectivePermissions( + selectedLinks.map((link) => link.collectionId as number) + ); + useLinks({ sort: sortBy }); + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }!` + ); + }; + const linkView = { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, @@ -41,17 +91,105 @@ export default function Links() { />
    + {links.length > 0 && ( +
    { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
    + )}
    + {editMode && ( +
    + {links.length > 0 && ( +
    + handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length}{" "} + {selectedLinks.length === 1 ? "link" : "links"} selected + + ) : ( + Nothing selected + )} +
    + )} +
    + + +
    +
    + )} + {links[0] ? ( - + ) : ( )}
    + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} ); } diff --git a/pages/links/pinned.tsx b/pages/links/pinned.tsx index c6b5ee0..4e8cc62 100644 --- a/pages/links/pinned.tsx +++ b/pages/links/pinned.tsx @@ -2,16 +2,22 @@ import SortDropdown from "@/components/SortDropdown"; import useLinks from "@/hooks/useLinks"; import MainLayout from "@/layouts/MainLayout"; import useLinkStore from "@/store/links"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import PageHeader from "@/components/PageHeader"; import { Sort, ViewMode } from "@/types/global"; import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; +import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; +import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; +import useCollectivePermissions from "@/hooks/useCollectivePermissions"; +import toast from "react-hot-toast"; // import GridView from "@/components/LinkViews/Layouts/GridView"; +import { useRouter } from "next/router"; export default function PinnedLinks() { - const { links } = useLinkStore(); + const { links, selectedLinks, deleteLinksById, setSelectedLinks } = + useLinkStore(); const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card @@ -20,6 +26,49 @@ export default function PinnedLinks() { useLinks({ sort: sortBy, pinnedOnly: true }); + const router = useRouter(); + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + const [editMode, setEditMode] = useState(false); + useEffect(() => { + return () => { + setEditMode(false); + }; + }, [router]); + + const collectivePermissions = useCollectivePermissions( + selectedLinks.map((link) => link.collectionId as number) + ); + + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }!` + ); + }; + const linkView = { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, @@ -39,13 +88,87 @@ export default function PinnedLinks() { description={"Pinned Links from your Collections"} />
    + {!(links.length === 0) && ( +
    { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
    + )}
    + {editMode && ( +
    + {links.length > 0 && ( +
    + handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length}{" "} + {selectedLinks.length === 1 ? "link" : "links"} selected + + ) : ( + Nothing selected + )} +
    + )} +
    + + +
    +
    + )} + {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( - + ) : (
    )}
    + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} ); } diff --git a/pages/tags/[id].tsx b/pages/tags/[id].tsx index 47b41e1..8924a3c 100644 --- a/pages/tags/[id].tsx +++ b/pages/tags/[id].tsx @@ -1,6 +1,6 @@ import useLinkStore from "@/store/links"; import { useRouter } from "next/router"; -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, use, useEffect, useState } from "react"; import MainLayout from "@/layouts/MainLayout"; import useTagStore from "@/store/tags"; import SortDropdown from "@/components/SortDropdown"; @@ -12,11 +12,15 @@ import CardView from "@/components/LinkViews/Layouts/CardView"; // import GridView from "@/components/LinkViews/Layouts/GridView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import { dropdownTriggerer } from "@/lib/client/utils"; +import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; +import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; +import useCollectivePermissions from "@/hooks/useCollectivePermissions"; export default function Index() { const router = useRouter(); - const { links } = useLinkStore(); + const { links, selectedLinks, deleteLinksById, setSelectedLinks } = + useLinkStore(); const { tags, updateTag, removeTag } = useTagStore(); const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); @@ -26,11 +30,31 @@ export default function Index() { const [activeTag, setActiveTag] = useState(); + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + const [editMode, setEditMode] = useState(false); + useEffect(() => { + return () => { + setEditMode(false); + }; + }, [router]); + + const collectivePermissions = useCollectivePermissions( + selectedLinks.map((link) => link.collectionId as number) + ); + useLinks({ tagId: Number(router.query.id), sort: sortBy }); useEffect(() => { - setActiveTag(tags.find((e) => e.id === Number(router.query.id))); - }, [router, tags]); + const tag = tags.find((e) => e.id === Number(router.query.id)); + + if (tags.length > 0 && !tag?.id) { + router.push("/dashboard"); + return; + } + + setActiveTag(tag); + }, [router, tags, Number(router.query.id), setActiveTag]); useEffect(() => { setNewTagName(activeTag?.name); @@ -91,6 +115,35 @@ export default function Index() { setRenameTag(false); }; + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }!` + ); + }; + const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card ); @@ -195,16 +248,102 @@ export default function Index() {
    +
    { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
    + {editMode && ( +
    + {links.length > 0 && ( +
    + handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length}{" "} + {selectedLinks.length === 1 ? "link" : "links"} selected + + ) : ( + Nothing selected + )} +
    + )} +
    + + +
    +
    + )} e.tags.some((e) => e.id === Number(router.query.id)) )} /> + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} ); } diff --git a/store/links.ts b/store/links.ts index ab74b03..408a3ee 100644 --- a/store/links.ts +++ b/store/links.ts @@ -10,10 +10,12 @@ type ResponseObject = { type LinkStore = { links: LinkIncludingShortenedCollectionAndTags[]; + selectedLinks: LinkIncludingShortenedCollectionAndTags[]; setLinks: ( data: LinkIncludingShortenedCollectionAndTags[], isInitialCall: boolean ) => void; + setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void; addLink: ( body: LinkIncludingShortenedCollectionAndTags ) => Promise; @@ -21,12 +23,22 @@ type LinkStore = { updateLink: ( link: LinkIncludingShortenedCollectionAndTags ) => Promise; + updateLinks: ( + links: LinkIncludingShortenedCollectionAndTags[], + removePreviousTags: boolean, + newData: Pick< + LinkIncludingShortenedCollectionAndTags, + "tags" | "collectionId" + > + ) => Promise; removeLink: (linkId: number) => Promise; + deleteLinksById: (linkIds: number[]) => Promise; resetLinks: () => void; }; const useLinkStore = create()((set) => ({ links: [], + selectedLinks: [], setLinks: async (data, isInitialCall) => { isInitialCall && set(() => ({ @@ -45,6 +57,7 @@ const useLinkStore = create()((set) => ({ ), })); }, + setSelectedLinks: (links) => set({ selectedLinks: links }), addLink: async (body) => { const response = await fetch("/api/v1/links", { body: JSON.stringify(body), @@ -122,6 +135,41 @@ const useLinkStore = create()((set) => ({ return { ok: response.ok, data: data.response }; }, + updateLinks: async (links, removePreviousTags, newData) => { + const response = await fetch("/api/v1/links", { + body: JSON.stringify({ links, removePreviousTags, newData }), + headers: { + "Content-Type": "application/json", + }, + method: "PUT", + }); + + const data = await response.json(); + + if (response.ok) { + set((state) => ({ + links: state.links.map((e) => + links.some((link) => link.id === e.id) + ? { + ...e, + collectionId: newData.collectionId ?? e.collectionId, + collection: { + ...e.collection, + id: newData.collectionId ?? e.collection.id, + }, + tags: removePreviousTags + ? [...(newData.tags ?? [])] + : [...e.tags, ...(newData.tags ?? [])], + } + : e + ), + })); + useTagStore.getState().setTags(); + useCollectionStore.getState().setCollections(); + } + + return { ok: response.ok, data: data.response }; + }, removeLink: async (linkId) => { const response = await fetch(`/api/v1/links/${linkId}`, { headers: { @@ -142,6 +190,27 @@ const useLinkStore = create()((set) => ({ return { ok: response.ok, data: data.response }; }, + deleteLinksById: async (linkIds: number[]) => { + const response = await fetch("/api/v1/links", { + body: JSON.stringify({ linkIds }), + headers: { + "Content-Type": "application/json", + }, + method: "DELETE", + }); + + const data = await response.json(); + + if (response.ok) { + set((state) => ({ + links: state.links.filter((e) => !linkIds.includes(e.id as number)), + })); + useTagStore.getState().setTags(); + useCollectionStore.getState().setCollections(); + } + + return { ok: response.ok, data: data.response }; + }, resetLinks: () => set({ links: [] }), })); diff --git a/store/localSettings.ts b/store/localSettings.ts index e38bae8..6c79d6b 100644 --- a/store/localSettings.ts +++ b/store/localSettings.ts @@ -1,5 +1,4 @@ import { create } from "zustand"; -import { ViewMode } from "@/types/global"; type LocalSettings = { theme?: string;