From d07f2c90065f53d36a3fc0e7db54c32d54a2a332 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Thu, 25 Apr 2024 20:15:15 +0100 Subject: feature(web): Add ability to rename, merge and fast delete tags. Fixes #105 (#125) * feature(web): Allow deleting tags from the all tags page * feature(web): Add ability to rename, merge and fast delete tags. Fixes #105 --- apps/web/components/dashboard/EditableText.tsx | 146 +++++++++++++++++++ .../web/components/dashboard/bookmarks/TagList.tsx | 2 +- .../components/dashboard/preview/EditableTitle.tsx | 155 ++++----------------- apps/web/components/dashboard/tags/AllTagsView.tsx | 40 ++++-- .../components/dashboard/tags/DeleteTagButton.tsx | 59 -------- .../dashboard/tags/DeleteTagConfirmationDialog.tsx | 62 +++++++++ .../components/dashboard/tags/EditableTagName.tsx | 61 ++++++++ .../components/dashboard/tags/MergeTagModal.tsx | 148 ++++++++++++++++++++ apps/web/components/dashboard/tags/TagOptions.tsx | 57 ++++++++ apps/web/components/dashboard/tags/TagSelector.tsx | 52 +++++++ 10 files changed, 584 insertions(+), 198 deletions(-) create mode 100644 apps/web/components/dashboard/EditableText.tsx delete mode 100644 apps/web/components/dashboard/tags/DeleteTagButton.tsx create mode 100644 apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx create mode 100644 apps/web/components/dashboard/tags/EditableTagName.tsx create mode 100644 apps/web/components/dashboard/tags/MergeTagModal.tsx create mode 100644 apps/web/components/dashboard/tags/TagOptions.tsx create mode 100644 apps/web/components/dashboard/tags/TagSelector.tsx (limited to 'apps/web/components') diff --git a/apps/web/components/dashboard/EditableText.tsx b/apps/web/components/dashboard/EditableText.tsx new file mode 100644 index 00000000..7539bd8f --- /dev/null +++ b/apps/web/components/dashboard/EditableText.tsx @@ -0,0 +1,146 @@ +import { useEffect, useRef, useState } from "react"; +import { ActionButtonWithTooltip } from "@/components/ui/action-button"; +import { ButtonWithTooltip } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Check, Pencil, X } from "lucide-react"; + +interface Props { + viewClassName?: string; + untitledClassName?: string; + editClassName?: string; + onSave: (title: string | null) => void; + isSaving: boolean; + originalText: string | null; + setEditable: (editable: boolean) => void; +} + +function EditMode({ + onSave: onSaveCB, + editClassName: className, + isSaving, + originalText, + setEditable, +}: Props) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.focus(); + ref.current.textContent = originalText; + } + }, [ref]); + + const onSave = () => { + let toSave: string | null = ref.current?.textContent ?? null; + if (originalText == toSave) { + // Nothing to do here + return; + } + if (toSave == "") { + toSave = null; + } + onSaveCB(toSave); + setEditable(false); + }; + + return ( +
+
{ + if (e.key === "Enter") { + e.preventDefault(); + } + }} + /> + onSave()} + > + + + { + setEditable(false); + }} + > + + +
+ ); +} + +function ViewMode({ + originalText, + setEditable, + viewClassName, + untitledClassName, +}: Props) { + return ( + +
+ + {originalText ? ( +

{originalText}

+ ) : ( +

Untitled

+ )} +
+ { + setEditable(true); + }} + > + + +
+ + {originalText && ( + + {originalText} + + )} + +
+ ); +} + +export function EditableText(props: { + viewClassName?: string; + untitledClassName?: string; + editClassName?: string; + originalText: string | null; + onSave: (title: string | null) => void; + isSaving: boolean; +}) { + const [editable, setEditable] = useState(false); + + return editable ? ( + + ) : ( + + ); +} diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx index ff63d110..ccf3bf09 100644 --- a/apps/web/components/dashboard/bookmarks/TagList.tsx +++ b/apps/web/components/dashboard/bookmarks/TagList.tsx @@ -32,7 +32,7 @@ export default function TagList({ badgeVariants({ variant: "outline" }), "text-nowrap font-normal hover:bg-foreground hover:text-secondary", )} - href={`/dashboard/tags/${t.name}`} + href={`/dashboard/tags/${t.id}`} > {t.name} diff --git a/apps/web/components/dashboard/preview/EditableTitle.tsx b/apps/web/components/dashboard/preview/EditableTitle.tsx index 071b3ca3..8067e23d 100644 --- a/apps/web/components/dashboard/preview/EditableTitle.tsx +++ b/apps/web/components/dashboard/preview/EditableTitle.tsx @@ -1,27 +1,11 @@ -import { useEffect, useRef, useState } from "react"; -import { ActionButtonWithTooltip } from "@/components/ui/action-button"; -import { ButtonWithTooltip } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipPortal, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { toast } from "@/components/ui/use-toast"; -import { Check, Pencil, X } from "lucide-react"; import { useUpdateBookmark } from "@hoarder/shared-react/hooks/bookmarks"; import { ZBookmark } from "@hoarder/shared/types/bookmarks"; -interface Props { - bookmarkId: string; - originalTitle: string | null; - setEditable: (editable: boolean) => void; -} - -function EditMode({ bookmarkId, originalTitle, setEditable }: Props) { - const ref = useRef(null); +import { EditableText } from "../EditableText"; +export function EditableTitle({ bookmark }: { bookmark: ZBookmark }) { const { mutate: updateBookmark, isPending } = useUpdateBookmark({ onSuccess: () => { toast({ @@ -30,107 +14,6 @@ function EditMode({ bookmarkId, originalTitle, setEditable }: Props) { }, }); - useEffect(() => { - if (ref.current) { - ref.current.focus(); - ref.current.textContent = originalTitle; - } - }, [ref]); - - const onSave = () => { - let toSave: string | null = ref.current?.textContent ?? null; - if (originalTitle == toSave) { - // Nothing to do here - return; - } - if (toSave == "") { - toSave = null; - } - updateBookmark({ - bookmarkId, - title: toSave, - }); - setEditable(false); - }; - - return ( -
-
{ - if (e.key === "Enter") { - e.preventDefault(); - } - }} - /> - onSave()} - > - - - { - setEditable(false); - }} - > - - -
- ); -} - -function ViewMode({ originalTitle, setEditable }: Props) { - return ( - -
- - {originalTitle ? ( -

{originalTitle}

- ) : ( -

Untitled

- )} -
- { - setEditable(true); - }} - > - - -
- - {originalTitle && ( - - {originalTitle} - - )} - -
- ); -} - -export function EditableTitle({ bookmark }: { bookmark: ZBookmark }) { - const [editable, setEditable] = useState(false); - let title: string | null = null; switch (bookmark.content.type) { case "link": @@ -149,17 +32,29 @@ export function EditableTitle({ bookmark }: { bookmark: ZBookmark }) { title = null; } - return editable ? ( - - ) : ( - { + updateBookmark( + { + bookmarkId: bookmark.id, + title: newTitle, + }, + { + onError: () => { + toast({ + description: "Something went wrong", + variant: "destructive", + }); + }, + }, + ); + }} + isSaving={isPending} /> ); } diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx index 73bfb7e6..1f9f2dba 100644 --- a/apps/web/components/dashboard/tags/AllTagsView.tsx +++ b/apps/web/components/dashboard/tags/AllTagsView.tsx @@ -1,20 +1,44 @@ "use client"; import Link from "next/link"; +import { Button } from "@/components/ui/button"; import InfoTooltip from "@/components/ui/info-tooltip"; import { Separator } from "@/components/ui/separator"; import { api } from "@/lib/trpc"; +import { X } from "lucide-react"; import type { ZGetTagResponse } from "@hoarder/shared/types/tags"; -function TagPill({ name, count }: { name: string; count: number }) { +import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog"; + +function TagPill({ + id, + name, + count, +}: { + id: string; + name: string; + count: number; +}) { return ( - - {name} {count} - +
+ + {name} {count} + + + + + +
); } @@ -36,7 +60,7 @@ export default function AllTagsView({ let tagPill; if (tags.length) { tagPill = tags.map((t) => ( - + )); } else { tagPill = "No Tags"; diff --git a/apps/web/components/dashboard/tags/DeleteTagButton.tsx b/apps/web/components/dashboard/tags/DeleteTagButton.tsx deleted file mode 100644 index 4cff1680..00000000 --- a/apps/web/components/dashboard/tags/DeleteTagButton.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { ActionButton } from "@/components/ui/action-button"; -import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { Button } from "@/components/ui/button"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; -import { Trash2 } from "lucide-react"; - -export default function DeleteTagButton({ - tagName, - tagId, -}: { - tagName: string; - tagId: string; -}) { - const router = useRouter(); - - const apiUtils = api.useUtils(); - - const { mutate: deleteTag, isPending } = api.tags.delete.useMutation({ - onSuccess: () => { - apiUtils.tags.list.invalidate(); - apiUtils.bookmarks.getBookmark.invalidate(); - toast({ - description: `Tag "${tagName}" has been deleted!`, - }); - router.push("/"); - }, - onError: () => { - toast({ - variant: "destructive", - description: `Something went wrong`, - }); - }, - }); - return ( - ( - deleteTag({ tagId: tagId })} - > - Delete - - )} - > - - - ); -} diff --git a/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx new file mode 100644 index 00000000..7021b715 --- /dev/null +++ b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx @@ -0,0 +1,62 @@ +import { usePathname, useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { toast } from "@/components/ui/use-toast"; + +import { useDeleteTag } from "@hoarder/shared-react/hooks/tags"; + +export default function DeleteTagConfirmationDialog({ + tag, + children, + open, + setOpen, +}: { + tag: { id: string; name: string }; + children?: React.ReactNode; + open?: boolean; + setOpen?: (v: boolean) => void; +}) { + const currentPath = usePathname(); + const router = useRouter(); + const { mutate: deleteTag, isPending } = useDeleteTag({ + onSuccess: () => { + toast({ + description: `Tag "${tag.name}" has been deleted!`, + }); + if (currentPath.includes(tag.id)) { + router.push("/dashboard/tags"); + } + }, + onError: () => { + toast({ + variant: "destructive", + description: `Something went wrong`, + }); + }, + }); + return ( + ( + + deleteTag( + { tagId: tag.id }, + { onSuccess: () => setDialogOpen(false) }, + ) + } + > + Delete + + )} + > + {children} + + ); +} diff --git a/apps/web/components/dashboard/tags/EditableTagName.tsx b/apps/web/components/dashboard/tags/EditableTagName.tsx new file mode 100644 index 00000000..9c8919b7 --- /dev/null +++ b/apps/web/components/dashboard/tags/EditableTagName.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { toast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; + +import { useUpdateTag } from "@hoarder/shared-react/hooks/tags"; + +import { EditableText } from "../EditableText"; + +export default function EditableTagName({ + tag, + className, +}: { + tag: { id: string; name: string }; + className?: string; +}) { + const router = useRouter(); + const currentPath = usePathname(); + const { mutate: updateTag, isPending } = useUpdateTag({ + onSuccess: () => { + toast({ + description: "Tag updated!", + }); + if (currentPath.includes(tag.id)) { + router.refresh(); + } + }, + }); + return ( + { + if (!newName || newName == "") { + toast({ + description: "You must set a name for the tag!", + variant: "destructive", + }); + return; + } + updateTag( + { + tagId: tag.id, + name: newName, + }, + { + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }, + ); + }} + isSaving={isPending} + /> + ); +} diff --git a/apps/web/components/dashboard/tags/MergeTagModal.tsx b/apps/web/components/dashboard/tags/MergeTagModal.tsx new file mode 100644 index 00000000..266cc5d2 --- /dev/null +++ b/apps/web/components/dashboard/tags/MergeTagModal.tsx @@ -0,0 +1,148 @@ +import { usePathname, useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { toast } from "@/components/ui/use-toast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useMergeTag } from "@hoarder/shared-react/hooks/tags"; + +import { TagSelector } from "./TagSelector"; + +export function MergeTagModal({ + open, + setOpen, + tag, + children, +}: { + open: boolean; + setOpen: (v: boolean) => void; + tag: { id: string; name: string }; + children?: React.ReactNode; +}) { + const currentPath = usePathname(); + const router = useRouter(); + const formSchema = z.object({ + intoTagId: z.string(), + }); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + intoTagId: undefined, + }, + }); + + const { mutate: mergeTag, isPending: isPending } = useMergeTag({ + onSuccess: (resp) => { + toast({ + description: "Tag has been updated!", + }); + setOpen(false); + if (currentPath.includes(tag.id)) { + router.push(`/dashboard/tags/${resp.mergedIntoTagId}`); + } + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + if (e.data.zodError) { + toast({ + variant: "destructive", + description: Object.values(e.data.zodError.fieldErrors) + .flat() + .join("\n"), + }); + } else { + toast({ + variant: "destructive", + description: e.message, + }); + } + } else { + toast({ + variant: "destructive", + title: "Something went wrong", + }); + } + }, + }); + + return ( + { + form.reset(); + setOpen(s); + }} + > + {children && {children}} + +
+ { + mergeTag({ + fromTagIds: [tag.id], + intoTagId: value.intoTagId, + }); + })} + > + + Merge Tag + + + + You're about to move all the bookmarks in the tag " + {tag.name}" into the tag you select. + + + { + return ( + + + + + + + ); + }} + /> + + + + + + Save + + + + +
+
+ ); +} diff --git a/apps/web/components/dashboard/tags/TagOptions.tsx b/apps/web/components/dashboard/tags/TagOptions.tsx new file mode 100644 index 00000000..1bd17902 --- /dev/null +++ b/apps/web/components/dashboard/tags/TagOptions.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Combine, Trash2 } from "lucide-react"; + +import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog"; +import { MergeTagModal } from "./MergeTagModal"; + +export function TagOptions({ + tag, + children, +}: { + tag: { id: string; name: string }; + children?: React.ReactNode; +}) { + const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false); + const [mergeTagDialogOpen, setMergeTagDialogOpen] = useState(false); + + return ( + + + + {children} + + setMergeTagDialogOpen(true)} + > + + Merge + + + setDeleteTagDialogOpen(true)} + > + + Delete + + + + ); +} diff --git a/apps/web/components/dashboard/tags/TagSelector.tsx b/apps/web/components/dashboard/tags/TagSelector.tsx new file mode 100644 index 00000000..afc7340b --- /dev/null +++ b/apps/web/components/dashboard/tags/TagSelector.tsx @@ -0,0 +1,52 @@ +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import LoadingSpinner from "@/components/ui/spinner"; +import { api } from "@/lib/trpc"; + +export function TagSelector({ + value, + onChange, + placeholder = "Select a tag", +}: { + value?: string | null; + onChange: (value: string) => void; + placeholder?: string; +}) { + const { data: allTags, isPending } = api.tags.list.useQuery(); + + if (isPending || !allTags) { + return ; + } + + allTags.tags = allTags.tags.sort((a, b) => a.name.localeCompare(b.name)); + + return ( + + ); +} -- cgit v1.2.3-70-g09d2