From ea31eb47aed7d5efb0a5f9ce4421ad79e8c4f628 Mon Sep 17 00:00:00 2001 From: Isaac Wise Date: Sat, 10 Feb 2024 00:37:48 -0600 Subject: [PATCH] Finished bulk delete links --- components/LinkViews/Layouts/CardView.tsx | 3 + components/LinkViews/Layouts/ListView.tsx | 3 + components/LinkViews/LinkCard.tsx | 3 +- components/LinkViews/LinkList.tsx | 2 +- .../ModalContent/BulkDeleteLinksModal.tsx | 65 +++++++++++++++++++ components/ModalContent/DeleteLinkModal.tsx | 1 - components/ModalContent/NewLinkModal.tsx | 6 +- .../controllers/links/bulk/deleteLinksById.ts | 48 ++++++++++++++ .../links/linkId/deleteLinkById.ts | 2 +- pages/api/v1/links/bulk/index.ts | 15 +++++ pages/collections/[id].tsx | 45 +++++++++---- store/links.ts | 22 +++++++ 12 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 components/ModalContent/BulkDeleteLinksModal.tsx create mode 100644 lib/api/controllers/links/bulk/deleteLinksById.ts create mode 100644 pages/api/v1/links/bulk/index.ts diff --git a/components/LinkViews/Layouts/CardView.tsx b/components/LinkViews/Layouts/CardView.tsx index 3e341cc..cb8c5af 100644 --- a/components/LinkViews/Layouts/CardView.tsx +++ b/components/LinkViews/Layouts/CardView.tsx @@ -3,8 +3,10 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; export default function CardView({ links, + showCheckbox = true, }: { links: LinkIncludingShortenedCollectionAndTags[]; + showCheckbox?: boolean; }) { return (
@@ -15,6 +17,7 @@ export default function CardView({ link={e} count={i} flipDropdown={i === links.length - 1} + showCheckbox={showCheckbox} /> ); })} diff --git a/components/LinkViews/Layouts/ListView.tsx b/components/LinkViews/Layouts/ListView.tsx index 1839284..aa42780 100644 --- a/components/LinkViews/Layouts/ListView.tsx +++ b/components/LinkViews/Layouts/ListView.tsx @@ -3,8 +3,10 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; export default function ListView({ links, + showCheckbox = true }: { links: LinkIncludingShortenedCollectionAndTags[]; + showCheckbox?: boolean; }) { return (
@@ -14,6 +16,7 @@ export default function ListView({ key={i} link={e} count={i} + showCheckbox={showCheckbox} flipDropdown={i === links.length - 1} /> ); diff --git a/components/LinkViews/LinkCard.tsx b/components/LinkViews/LinkCard.tsx index d6d52ee..8e7f932 100644 --- a/components/LinkViews/LinkCard.tsx +++ b/components/LinkViews/LinkCard.tsx @@ -16,6 +16,7 @@ import Link from "next/link"; import LinkIcon from "./LinkComponents/LinkIcon"; import useOnScreen from "@/hooks/useOnScreen"; import { generateLinkHref } from "@/lib/client/generateLinkHref"; +import useAccountStore from "@/store/account"; type Props = { link: LinkIncludingShortenedCollectionAndTags; @@ -28,7 +29,7 @@ type Props = { export default function LinkCard({ link, flipDropdown, - showCheckbox, + showCheckbox = true, }: Props) { const { collections } = useCollectionStore(); diff --git a/components/LinkViews/LinkList.tsx b/components/LinkViews/LinkList.tsx index 27e1859..e5c07b2 100644 --- a/components/LinkViews/LinkList.tsx +++ b/components/LinkViews/LinkList.tsx @@ -25,7 +25,7 @@ type Props = { export default function LinkCardCompact({ link, flipDropdown, - showCheckbox, + showCheckbox = true, }: Props) { const { collections } = useCollectionStore(); const { links, setSelectedLinks, selectedLinks } = useLinkStore(); diff --git a/components/ModalContent/BulkDeleteLinksModal.tsx b/components/ModalContent/BulkDeleteLinksModal.tsx new file mode 100644 index 0000000..69ba026 --- /dev/null +++ b/components/ModalContent/BulkDeleteLinksModal.tsx @@ -0,0 +1,65 @@ +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); + + toast.dismiss(load); + + response.ok && toast.success(`Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : ""}!`); + + setSelectedLinks([]); + onClose(); + }; + + return ( + +

Delete {selectedLinks.length}

+ +
+ +
+ {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/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/NewLinkModal.tsx b/components/ModalContent/NewLinkModal.tsx index 20bcef2..3fde214 100644 --- a/components/ModalContent/NewLinkModal.tsx +++ b/components/ModalContent/NewLinkModal.tsx @@ -20,6 +20,7 @@ export default function NewLinkModal({ onClose }: Props) { const { data } = useSession(); const initial = { + id: 0, name: "", url: "", description: "", @@ -196,9 +197,8 @@ export default function NewLinkModal({ onClose }: Props) { {optionsExpanded ? "Hide" : "More"} Options

diff --git a/lib/api/controllers/links/bulk/deleteLinksById.ts b/lib/api/controllers/links/bulk/deleteLinksById.ts new file mode 100644 index 0000000..0bc93b0 --- /dev/null +++ b/lib/api/controllers/links/bulk/deleteLinksById.ts @@ -0,0 +1,48 @@ +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[]) { + console.log("linkIds: ", linkIds); + if (!linkIds || linkIds.length === 0) { + return { response: "Please choose valid links.", status: 401 }; + } + + const deletedLinks = []; + + 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 }; + } + + const deletedLink = await prisma.link.delete({ + where: { + id: linkId, + }, + }); + + removeFile({ + filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`, + }); + removeFile({ + filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`, + }); + removeFile({ + filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`, + }); + + deletedLinks.push(deletedLink); + } + + return { response: deletedLinks, status: 200 }; +} \ No newline at end of file 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/pages/api/v1/links/bulk/index.ts b/pages/api/v1/links/bulk/index.ts new file mode 100644 index 0000000..8a5583b --- /dev/null +++ b/pages/api/v1/links/bulk/index.ts @@ -0,0 +1,15 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import verifyUser from "@/lib/api/verifyUser"; +import deleteLinksById from "@/lib/api/controllers/links/bulk/deleteLinksById"; + +export default async function links(req: NextApiRequest, res: NextApiResponse) { + const user = await verifyUser({ req, res }); + if (!user) return; + + 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 ef84a39..c3b2964 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -25,13 +25,15 @@ import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import { dropdownTriggerer } from "@/lib/client/utils"; import NewCollectionModal from "@/components/ModalContent/NewCollectionModal"; +import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; +import toast from "react-hot-toast"; export default function Index() { const { settings } = useLocalSettingsStore(); const router = useRouter(); - const { links, selectedLinks, setSelectedLinks } = useLinkStore(); + const { links, selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore(); const { collections } = useCollectionStore(); const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); @@ -90,6 +92,7 @@ export default function Index() { const [editCollectionSharingModal, setEditCollectionSharingModal] = useState(false); const [deleteCollectionModal, setDeleteCollectionModal] = useState(false); + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card @@ -112,6 +115,16 @@ export default function Index() { } }; + const bulkDeleteLinks = async () => { + const load = toast.loading(`Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : ""}...`); + + const response = await deleteLinksById(selectedLinks); + + toast.dismiss(load); + + response.ok && toast.success(`Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : ""}!`); + }; + return (
handleSelectAll()} - checked={selectedLinks.length === links.length} + checked={selectedLinks.length === links.length && links.length > 0} /> {selectedLinks.length > 0 && ( @@ -308,7 +321,10 @@ export default function Index() { -
@@ -325,34 +341,37 @@ 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)} /> + )} - ) : undefined} + )} ); } diff --git a/store/links.ts b/store/links.ts index aa55b59..2393c19 100644 --- a/store/links.ts +++ b/store/links.ts @@ -24,6 +24,7 @@ type LinkStore = { link: LinkIncludingShortenedCollectionAndTags ) => Promise; removeLink: (linkId: number) => Promise; + deleteLinksById: (linkIds: number[]) => Promise; resetLinks: () => void; }; @@ -146,6 +147,27 @@ const useLinkStore = create()((set) => ({ return { ok: response.ok, data: data.response }; }, + deleteLinksById: async (linkIds: number[]) => { + const response = await fetch("/api/v1/links/bulk", { + 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)), + })); + useTagStore.getState().setTags(); + useCollectionStore.getState().setCollections(); + } + + return { ok: response.ok, data: data.response }; + }, resetLinks: () => set({ links: [] }), }));