aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/dashboard/tags/[tagId]/page.tsx (renamed from apps/web/app/dashboard/tags/[tagName]/page.tsx)23
-rw-r--r--apps/web/components/dashboard/EditableText.tsx146
-rw-r--r--apps/web/components/dashboard/bookmarks/TagList.tsx2
-rw-r--r--apps/web/components/dashboard/preview/EditableTitle.tsx155
-rw-r--r--apps/web/components/dashboard/tags/AllTagsView.tsx40
-rw-r--r--apps/web/components/dashboard/tags/DeleteTagButton.tsx59
-rw-r--r--apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx62
-rw-r--r--apps/web/components/dashboard/tags/EditableTagName.tsx61
-rw-r--r--apps/web/components/dashboard/tags/MergeTagModal.tsx148
-rw-r--r--apps/web/components/dashboard/tags/TagOptions.tsx57
-rw-r--r--apps/web/components/dashboard/tags/TagSelector.tsx52
-rw-r--r--packages/shared-react/hooks/tags.ts55
-rw-r--r--packages/trpc/routers/tags.ts182
13 files changed, 804 insertions, 238 deletions
diff --git a/apps/web/app/dashboard/tags/[tagName]/page.tsx b/apps/web/app/dashboard/tags/[tagId]/page.tsx
index b8bf351d..f6e02a65 100644
--- a/apps/web/app/dashboard/tags/[tagName]/page.tsx
+++ b/apps/web/app/dashboard/tags/[tagId]/page.tsx
@@ -1,19 +1,20 @@
import { notFound } from "next/navigation";
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
-import DeleteTagButton from "@/components/dashboard/tags/DeleteTagButton";
+import EditableTagName from "@/components/dashboard/tags/EditableTagName";
+import { TagOptions } from "@/components/dashboard/tags/TagOptions";
+import { Button } from "@/components/ui/button";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
+import { MoreHorizontal } from "lucide-react";
export default async function TagPage({
params,
}: {
- params: { tagName: string };
+ params: { tagId: string };
}) {
- const tagName = decodeURIComponent(params.tagName);
-
let tag;
try {
- tag = await api.tags.get({ tagName });
+ tag = await api.tags.get({ tagId: params.tagId });
} catch (e) {
if (e instanceof TRPCError) {
if (e.code == "NOT_FOUND") {
@@ -27,8 +28,16 @@ export default async function TagPage({
<Bookmarks
header={
<div className="flex justify-between">
- <span className="text-2xl">{tagName}</span>
- <DeleteTagButton tagName={tag.name} tagId={tag.id} />
+ <EditableTagName
+ tag={{ id: tag.id, name: tag.name }}
+ className="text-2xl"
+ />
+
+ <TagOptions tag={tag}>
+ <Button variant="ghost">
+ <MoreHorizontal />
+ </Button>
+ </TagOptions>
</div>
}
query={{ tagId: tag.id }}
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&apos;re about to move all the bookmarks in the tag &quot;
+ {tag.name}&quot; 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&apos;t currently have any tags.
+ </SelectItem>
+ )}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ );
+}
diff --git a/packages/shared-react/hooks/tags.ts b/packages/shared-react/hooks/tags.ts
new file mode 100644
index 00000000..d3129fed
--- /dev/null
+++ b/packages/shared-react/hooks/tags.ts
@@ -0,0 +1,55 @@
+import { api } from "../trpc";
+
+export function useUpdateTag(
+ ...opts: Parameters<typeof api.tags.update.useMutation>
+) {
+ const apiUtils = api.useUtils();
+
+ return api.tags.update.useMutation({
+ ...opts[0],
+ onSuccess: (res, req, meta) => {
+ apiUtils.tags.list.invalidate();
+ apiUtils.tags.get.invalidate({ tagId: res.id });
+ apiUtils.bookmarks.getBookmarks.invalidate({ tagId: res.id });
+
+ // TODO: Maybe we can only look at the cache and invalidate only affected bookmarks
+ apiUtils.bookmarks.getBookmark.invalidate();
+ return opts[0]?.onSuccess?.(res, req, meta);
+ },
+ });
+}
+
+export function useMergeTag(
+ ...opts: Parameters<typeof api.tags.merge.useMutation>
+) {
+ const apiUtils = api.useUtils();
+
+ return api.tags.merge.useMutation({
+ ...opts[0],
+ onSuccess: (res, req, meta) => {
+ apiUtils.tags.list.invalidate();
+ [res.mergedIntoTagId, ...res.deletedTags].forEach((tagId) => {
+ apiUtils.tags.get.invalidate({ tagId });
+ apiUtils.bookmarks.getBookmarks.invalidate({ tagId });
+ });
+ // TODO: Maybe we can only look at the cache and invalidate only affected bookmarks
+ apiUtils.bookmarks.getBookmark.invalidate();
+ return opts[0]?.onSuccess?.(res, req, meta);
+ },
+ });
+}
+
+export function useDeleteTag(
+ ...opts: Parameters<typeof api.tags.delete.useMutation>
+) {
+ const apiUtils = api.useUtils();
+
+ return api.tags.delete.useMutation({
+ ...opts[0],
+ onSuccess: (res, req, meta) => {
+ apiUtils.tags.list.invalidate();
+ apiUtils.bookmarks.getBookmark.invalidate();
+ return opts[0]?.onSuccess?.(res, req, meta);
+ },
+ });
+}
diff --git a/packages/trpc/routers/tags.ts b/packages/trpc/routers/tags.ts
index ed4ac7d2..c50291d1 100644
--- a/packages/trpc/routers/tags.ts
+++ b/packages/trpc/routers/tags.ts
@@ -1,32 +1,22 @@
import { experimental_trpcMiddleware, TRPCError } from "@trpc/server";
-import { and, count, eq } from "drizzle-orm";
+import { and, count, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import type { ZAttachedByEnum } from "@hoarder/shared/types/tags";
+import { SqliteError } from "@hoarder/db";
import { bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema";
import { zGetTagResponseSchema } from "@hoarder/shared/types/tags";
import type { Context } from "../index";
import { authedProcedure, router } from "../index";
-function conditionFromInput(
- input: { tagName: string } | { tagId: string },
- userId: string,
-) {
- if ("tagName" in input) {
- // Tag names are not unique, we must include userId in the condition
- return and(
- eq(bookmarkTags.name, input.tagName),
- eq(bookmarkTags.userId, userId),
- );
- } else {
- return eq(bookmarkTags.id, input.tagId);
- }
+function conditionFromInput(input: { tagId: string }, userId: string) {
+ return and(eq(bookmarkTags.id, input.tagId), eq(bookmarkTags.userId, userId));
}
const ensureTagOwnership = experimental_trpcMiddleware<{
ctx: Context;
- input: { tagName: string } | { tagId: string };
+ input: { tagId: string };
}>().create(async (opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
@@ -60,15 +50,9 @@ const ensureTagOwnership = experimental_trpcMiddleware<{
export const tagsAppRouter = router({
get: authedProcedure
.input(
- z
- .object({
- tagId: z.string(),
- })
- .or(
- z.object({
- tagName: z.string(),
- }),
- ),
+ z.object({
+ tagId: z.string(),
+ }),
)
.output(zGetTagResponseSchema)
.use(ensureTagOwnership)
@@ -111,15 +95,9 @@ export const tagsAppRouter = router({
}),
delete: authedProcedure
.input(
- z
- .object({
- tagId: z.string(),
- })
- .or(
- z.object({
- tagName: z.string(),
- }),
- ),
+ z.object({
+ tagId: z.string(),
+ }),
)
.use(ensureTagOwnership)
.mutation(async ({ input, ctx }) => {
@@ -135,6 +113,144 @@ export const tagsAppRouter = router({
throw new TRPCError({ code: "NOT_FOUND" });
}
}),
+ update: authedProcedure
+ .input(
+ z.object({
+ tagId: z.string(),
+ name: z.string().optional(),
+ }),
+ )
+ .output(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ userId: z.string(),
+ createdAt: z.date(),
+ }),
+ )
+ .use(ensureTagOwnership)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const res = await ctx.db
+ .update(bookmarkTags)
+ .set({
+ name: input.name,
+ })
+ .where(
+ and(
+ eq(bookmarkTags.id, input.tagId),
+ eq(bookmarkTags.userId, ctx.user.id),
+ ),
+ )
+ .returning();
+
+ if (res.length == 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ return res[0];
+ } catch (e) {
+ if (e instanceof SqliteError) {
+ if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Tag name already exists. You might want to consider a merge instead.",
+ });
+ }
+ }
+ throw e;
+ }
+ }),
+ merge: authedProcedure
+ .input(
+ z.object({
+ intoTagId: z.string(),
+ fromTagIds: z.array(z.string()),
+ }),
+ )
+ .output(
+ z.object({
+ mergedIntoTagId: z.string(),
+ deletedTags: z.array(z.string()),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const requestedTags = new Set([input.intoTagId, ...input.fromTagIds]);
+ if (requestedTags.size == 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No tags provided",
+ });
+ }
+ if (input.fromTagIds.includes(input.intoTagId)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Cannot merge tag into itself",
+ });
+ }
+ const affectedTags = await ctx.db.query.bookmarkTags.findMany({
+ where: and(
+ eq(bookmarkTags.userId, ctx.user.id),
+ inArray(bookmarkTags.id, [...requestedTags]),
+ ),
+ columns: {
+ id: true,
+ userId: true,
+ },
+ });
+ if (affectedTags.some((t) => t.userId != ctx.user.id)) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+ if (affectedTags.length != requestedTags.size) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "One or more tags not found",
+ });
+ }
+
+ const deletedTags = await ctx.db.transaction(async (trx) => {
+ // Not entirely sure what happens with a racing transaction that adds a to-be-deleted tag on a bookmark. But it's fine for now.
+
+ // NOTE: You can't really do an update here as you might violate the uniquness constraint if the info tag is already attached to the bookmark.
+ // There's no OnConflict handling for updates in drizzle.
+
+ // Unlink old tags
+ const unlinked = await trx
+ .delete(tagsOnBookmarks)
+ .where(and(inArray(tagsOnBookmarks.tagId, input.fromTagIds)))
+ .returning();
+
+ // Re-attach them to the new tag
+ await trx
+ .insert(tagsOnBookmarks)
+ .values(
+ unlinked.map((u) => ({
+ ...u,
+ tagId: input.intoTagId,
+ })),
+ )
+ .onConflictDoNothing();
+
+ // Delete the old tags
+ return await trx
+ .delete(bookmarkTags)
+ .where(
+ and(
+ inArray(bookmarkTags.id, input.fromTagIds),
+ eq(bookmarkTags.userId, ctx.user.id),
+ ),
+ )
+ .returning({ id: bookmarkTags.id });
+ });
+ return {
+ deletedTags: deletedTags.map((t) => t.id),
+ mergedIntoTagId: input.intoTagId,
+ };
+ }),
list: authedProcedure
.output(
z.object({