diff options
| author | Mohamed Bassem <me@mbassem.com> | 2024-04-25 20:15:15 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-04-25 20:15:15 +0100 |
| commit | d07f2c90065f53d36a3fc0e7db54c32d54a2a332 (patch) | |
| tree | 27102aeb30ee9798ca639517ff577bc7d135a4b4 /apps/web/components/dashboard | |
| parent | da6df7c7853e9c8350e52d6f4c17021667caf8b8 (diff) | |
| download | karakeep-d07f2c90065f53d36a3fc0e7db54c32d54a2a332.tar.zst | |
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
Diffstat (limited to 'apps/web/components/dashboard')
10 files changed, 584 insertions, 198 deletions
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<HTMLDivElement>(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 ( + <div className="flex gap-3"> + <div + ref={ref} + role="presentation" + className={className} + contentEditable={true} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + } + }} + /> + <ActionButtonWithTooltip + tooltip="Save" + delayDuration={500} + size="none" + variant="ghost" + className="align-middle text-gray-400" + loading={isSaving} + onClick={() => onSave()} + > + <Check className="size-4" /> + </ActionButtonWithTooltip> + <ButtonWithTooltip + tooltip="Cancel" + delayDuration={500} + size="none" + variant="ghost" + className="align-middle text-gray-400" + onClick={() => { + setEditable(false); + }} + > + <X className="size-4" /> + </ButtonWithTooltip> + </div> + ); +} + +function ViewMode({ + originalText, + setEditable, + viewClassName, + untitledClassName, +}: Props) { + return ( + <Tooltip delayDuration={500}> + <div className="flex items-center gap-3 text-center"> + <TooltipTrigger asChild> + {originalText ? ( + <p className={viewClassName}>{originalText}</p> + ) : ( + <p className={untitledClassName}>Untitled</p> + )} + </TooltipTrigger> + <ButtonWithTooltip + delayDuration={500} + tooltip="Edit title" + size="none" + variant="ghost" + className="align-middle text-gray-400" + onClick={() => { + setEditable(true); + }} + > + <Pencil className="size-4" /> + </ButtonWithTooltip> + </div> + <TooltipPortal> + {originalText && ( + <TooltipContent side="bottom" className="max-w-[40ch]"> + {originalText} + </TooltipContent> + )} + </TooltipPortal> + </Tooltip> + ); +} + +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 ? ( + <EditMode setEditable={setEditable} {...props} /> + ) : ( + <ViewMode setEditable={setEditable} {...props} /> + ); +} 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} </Link> 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<HTMLDivElement>(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 ( - <div className="flex gap-3"> - <div - ref={ref} - role="presentation" - className="p-2 text-center text-lg" - contentEditable={true} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - } - }} - /> - <ActionButtonWithTooltip - tooltip="Save" - delayDuration={500} - size="none" - variant="ghost" - className="align-middle text-gray-400" - loading={isPending} - onClick={() => onSave()} - > - <Check className="size-4" /> - </ActionButtonWithTooltip> - <ButtonWithTooltip - tooltip="Cancel" - delayDuration={500} - size="none" - variant="ghost" - className="align-middle text-gray-400" - onClick={() => { - setEditable(false); - }} - > - <X className="size-4" /> - </ButtonWithTooltip> - </div> - ); -} - -function ViewMode({ originalTitle, setEditable }: Props) { - return ( - <Tooltip delayDuration={500}> - <div className="flex items-center gap-3 text-center"> - <TooltipTrigger asChild> - {originalTitle ? ( - <p className="line-clamp-2 text-lg">{originalTitle}</p> - ) : ( - <p className="text-lg italic text-gray-600">Untitled</p> - )} - </TooltipTrigger> - <ButtonWithTooltip - delayDuration={500} - tooltip="Edit title" - size="none" - variant="ghost" - className="align-middle text-gray-400" - onClick={() => { - setEditable(true); - }} - > - <Pencil className="size-4" /> - </ButtonWithTooltip> - </div> - <TooltipPortal> - {originalTitle && ( - <TooltipContent side="bottom" className="max-w-[40ch]"> - {originalTitle} - </TooltipContent> - )} - </TooltipPortal> - </Tooltip> - ); -} - -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 ? ( - <EditMode - bookmarkId={bookmark.id} - originalTitle={title} - setEditable={setEditable} - /> - ) : ( - <ViewMode - bookmarkId={bookmark.id} - originalTitle={title} - setEditable={setEditable} + return ( + <EditableText + originalText={title} + editClassName="p-2 text-center text-lg" + viewClassName="line-clamp-2 text-lg" + untitledClassName="text-lg italic text-gray-600" + onSave={(newTitle) => { + 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 ( - <Link - className="flex gap-2 rounded-md border border-border bg-background px-2 py-1 text-foreground hover:bg-foreground hover:text-background" - href={`/dashboard/tags/${name}`} - > - {name} <Separator orientation="vertical" /> {count} - </Link> + <div className="group relative flex"> + <Link + className="flex gap-2 rounded-md border border-border bg-background px-2 py-1 text-foreground hover:bg-foreground hover:text-background" + href={`/dashboard/tags/${id}`} + > + {name} <Separator orientation="vertical" /> {count} + </Link> + + <DeleteTagConfirmationDialog tag={{ name, id }}> + <Button + size="none" + variant="secondary" + className="-translate-1/2 absolute -right-1 -top-1 hidden rounded-full group-hover:block" + > + <X className="size-3" /> + </Button> + </DeleteTagConfirmationDialog> + </div> ); } @@ -36,7 +60,7 @@ export default function AllTagsView({ let tagPill; if (tags.length) { tagPill = tags.map((t) => ( - <TagPill key={t.id} name={t.name} count={t.count} /> + <TagPill key={t.id} id={t.id} name={t.name} count={t.count} /> )); } 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 ( - <ActionConfirmingDialog - title={`Delete ${tagName}?`} - description={`Are you sure you want to delete the tag "${tagName}"?`} - actionButton={() => ( - <ActionButton - type="button" - variant="destructive" - loading={isPending} - onClick={() => deleteTag({ tagId: tagId })} - > - Delete - </ActionButton> - )} - > - <Button className="mt-auto flex gap-2" variant="destructiveOutline"> - <Trash2 className="size-5" /> - <span className="hidden md:block">Delete Tag</span> - </Button> - </ActionConfirmingDialog> - ); -} 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 ( + <ActionConfirmingDialog + open={open} + setOpen={setOpen} + title={`Delete ${tag.name}?`} + description={`Are you sure you want to delete the tag "${tag.name}"?`} + actionButton={(setDialogOpen) => ( + <ActionButton + type="button" + variant="destructive" + loading={isPending} + onClick={() => + deleteTag( + { tagId: tag.id }, + { onSuccess: () => setDialogOpen(false) }, + ) + } + > + Delete + </ActionButton> + )} + > + {children} + </ActionConfirmingDialog> + ); +} 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 ( + <EditableText + viewClassName={className} + editClassName={cn("p-2", className)} + originalText={tag.name} + onSave={(newName) => { + 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<z.infer<typeof formSchema>>({ + 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 ( + <Dialog + open={open} + onOpenChange={(s) => { + form.reset(); + setOpen(s); + }} + > + {children && <DialogTrigger asChild>{children}</DialogTrigger>} + <DialogContent> + <Form {...form}> + <form + onSubmit={form.handleSubmit((value) => { + mergeTag({ + fromTagIds: [tag.id], + intoTagId: value.intoTagId, + }); + })} + > + <DialogHeader> + <DialogTitle>Merge Tag</DialogTitle> + </DialogHeader> + + <DialogDescription className="pt-4"> + You're about to move all the bookmarks in the tag " + {tag.name}" into the tag you select. + </DialogDescription> + + <FormField + control={form.control} + name="intoTagId" + render={({ field }) => { + return ( + <FormItem className="grow py-4"> + <FormControl> + <TagSelector + value={field.value} + onChange={field.onChange} + placeholder="Select a tag to merge into" + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton type="submit" loading={isPending}> + Save + </ActionButton> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} 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 ( + <DropdownMenu> + <DeleteTagConfirmationDialog + tag={tag} + open={deleteTagDialogOpen} + setOpen={setDeleteTagDialogOpen} + /> + <MergeTagModal + open={mergeTagDialogOpen} + setOpen={setMergeTagDialogOpen} + tag={tag} + /> + <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> + <DropdownMenuContent> + <DropdownMenuItem + className="flex gap-2" + onClick={() => setMergeTagDialogOpen(true)} + > + <Combine className="size-4" /> + <span>Merge</span> + </DropdownMenuItem> + + <DropdownMenuItem + className="flex gap-2" + onClick={() => setDeleteTagDialogOpen(true)} + > + <Trash2 className="size-4" /> + <span>Delete</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); +} 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 <LoadingSpinner />; + } + + allTags.tags = allTags.tags.sort((a, b) => a.name.localeCompare(b.name)); + + return ( + <Select onValueChange={onChange} value={value ?? ""}> + <SelectTrigger className="w-full"> + <SelectValue placeholder={placeholder} /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {allTags?.tags.map((tag) => { + return ( + <SelectItem key={tag.id} value={tag.id}> + {tag.name} + </SelectItem> + ); + })} + {allTags && allTags.tags.length == 0 && ( + <SelectItem value="notag" disabled> + You don't currently have any tags. + </SelectItem> + )} + </SelectGroup> + </SelectContent> + </Select> + ); +} |
