diff options
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/app/dashboard/tags/page.tsx | 16 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/TagsEditor.tsx | 233 | ||||
| -rw-r--r-- | apps/web/package.json | 1 |
3 files changed, 121 insertions, 129 deletions
diff --git a/apps/web/app/dashboard/tags/page.tsx b/apps/web/app/dashboard/tags/page.tsx index 08acd968..dec11527 100644 --- a/apps/web/app/dashboard/tags/page.tsx +++ b/apps/web/app/dashboard/tags/page.tsx @@ -1,11 +1,8 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { Separator } from "@/components/ui/separator"; +import { api } from "@/server/api/client"; import { getServerAuthSession } from "@/server/auth"; -import { count, eq } from "drizzle-orm"; - -import { db } from "@hoarder/db"; -import { bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema"; function TagPill({ name, count }: { name: string; count: number }) { return ( @@ -24,16 +21,7 @@ export default async function TagsPage() { redirect("/"); } - let tags = await db - .select({ - id: tagsOnBookmarks.tagId, - name: bookmarkTags.name, - count: count(), - }) - .from(tagsOnBookmarks) - .where(eq(bookmarkTags.userId, session.user.id)) - .groupBy(tagsOnBookmarks.tagId) - .innerJoin(bookmarkTags, eq(bookmarkTags.id, tagsOnBookmarks.tagId)); + let tags = (await api.tags.list()).tags; // Sort tags by usage desc tags = tags.sort((a, b) => b.count - a.count); diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx index 12c0dcd0..38f01bdd 100644 --- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx @@ -1,134 +1,137 @@ -import type { KeyboardEvent } from "react"; -import { useEffect, useState } from "react"; -import { Input } from "@/components/ui/input"; +import type { ActionMeta } from "react-select"; import { toast } from "@/components/ui/use-toast"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; -import { Sparkles, X } from "lucide-react"; +import { Sparkles } from "lucide-react"; +import CreateableSelect from "react-select/creatable"; import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; import type { ZAttachedByEnum } from "@hoarder/trpc/types/tags"; interface EditableTag { attachedBy: ZAttachedByEnum; - id?: string; - name: string; -} - -function TagAddInput({ addTag }: { addTag: (tag: string) => void }) { - const onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => { - if (e.key === "Enter") { - addTag(e.currentTarget.value); - e.currentTarget.value = ""; - } - }; - return ( - <Input - onKeyUp={onKeyUp} - className="h-8 w-full border-none bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" - /> - ); -} - -function TagPill({ - tag, - deleteCB, -}: { - tag: { attachedBy: ZAttachedByEnum; id?: string; name: string }; - deleteCB: () => void; -}) { - const isAttachedByAI = tag.attachedBy == "ai"; - return ( - <div - className={cn( - "flex min-h-8 space-x-1 rounded px-2", - isAttachedByAI - ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white" - : "bg-gray-200", - )} - > - {isAttachedByAI && <Sparkles className="m-auto size-4" />} - <p className="m-auto">{tag.name}</p> - <button className="m-auto size-4" onClick={deleteCB}> - <X className="size-4" /> - </button> - </div> - ); + value?: string; + label: string; } export function TagsEditor({ bookmark }: { bookmark: ZBookmark }) { - const [tags, setTags] = useState<Map<string, EditableTag>>(new Map()); - useEffect(() => { - const m = new Map<string, EditableTag>(); - for (const t of bookmark.tags) { - m.set(t.name, { attachedBy: t.attachedBy, id: t.id, name: t.name }); - } - setTags(m); - }, [bookmark.tags]); - const bookmarkInvalidationFunction = api.useUtils().bookmarks.getBookmark.invalidate; - const { mutate } = api.bookmarks.updateTags.useMutation({ - onSuccess: () => { - toast({ - description: "Tags has been updated!", - }); - bookmarkInvalidationFunction({ bookmarkId: bookmark.id }); - // TODO(bug) Invalidate the tag views as well - }, - onError: () => { - toast({ - variant: "destructive", - title: "Something went wrong", - description: "There was a problem with your request.", - }); - }, - }); + const { mutate, isPending: isMutating } = + api.bookmarks.updateTags.useMutation({ + onSuccess: () => { + toast({ + description: "Tags has been updated!", + }); + bookmarkInvalidationFunction({ bookmarkId: bookmark.id }); + // TODO(bug) Invalidate the tag views as well + }, + onError: () => { + toast({ + variant: "destructive", + title: "Something went wrong", + description: "There was a problem with your request.", + }); + }, + }); + + const { data: existingTags, isLoading: isExistingTagsLoading } = + api.tags.list.useQuery(); + + const onChange = ( + _option: readonly EditableTag[], + actionMeta: ActionMeta<EditableTag>, + ) => { + switch (actionMeta.action) { + case "remove-value": { + if (actionMeta.removedValue.value) { + mutate({ + bookmarkId: bookmark.id, + attach: [], + detach: [{ tagId: actionMeta.removedValue.value }], + }); + } + break; + } + case "create-option": { + mutate({ + bookmarkId: bookmark.id, + attach: [{ tag: actionMeta.option.label }], + detach: [], + }); + break; + } + case "select-option": { + if (actionMeta.option) { + mutate({ + bookmarkId: bookmark.id, + attach: [ + { tag: actionMeta.option.label, tagId: actionMeta.option?.value }, + ], + detach: [], + }); + } + break; + } + } + }; return ( - <div className="flex flex-wrap gap-2 rounded border p-2"> - {[...tags.values()].map((t) => ( - <TagPill - key={t.name} - tag={t} - deleteCB={() => { - setTags((m) => { - const newMap = new Map(m); - newMap.delete(t.name); - if (t.id) { - mutate({ - bookmarkId: bookmark.id, - attach: [], - detach: [{ tagId: t.id }], - }); - } - return newMap; - }); - }} - /> - ))} - <div className="flex-1"> - <TagAddInput - addTag={(val) => { - setTags((m) => { - if (m.has(val)) { - // Tag already exists - // Do nothing - return m; - } - const newMap = new Map(m); - newMap.set(val, { attachedBy: "human", name: val }); - mutate({ - bookmarkId: bookmark.id, - attach: [{ tag: val }], - detach: [], - }); - return newMap; - }); - }} - /> - </div> - </div> + <CreateableSelect + onChange={onChange} + options={ + existingTags?.tags.map((t) => ({ + label: t.name, + value: t.id, + attachedBy: "human" as const, + })) ?? [] + } + value={bookmark.tags.map((t) => ({ + label: t.name, + value: t.id, + attachedBy: t.attachedBy, + }))} + isMulti + closeMenuOnSelect={false} + isClearable={false} + isLoading={isExistingTagsLoading || isMutating} + styles={{ + multiValueRemove: () => ({ + "background-color": "transparent", + }), + valueContainer: (styles) => ({ + ...styles, + padding: "0.5rem", + }), + }} + components={{ + MultiValueContainer: ({ children, data }) => ( + <div + className={cn( + "flex min-h-8 space-x-1 rounded px-2", + (data as { attachedBy: string }).attachedBy == "ai" + ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white" + : "bg-gray-200", + )} + > + {children} + </div> + ), + MultiValueLabel: ({ children, data }) => ( + <div className="m-auto flex gap-2"> + {(data as { attachedBy: string }).attachedBy == "ai" && ( + <Sparkles className="m-auto size-4" /> + )} + {children} + </div> + ), + }} + classNames={{ + multiValueRemove: () => "my-auto", + valueContainer: () => "gap-2", + menuList: () => "text-sm", + }} + /> ); } diff --git a/apps/web/package.json b/apps/web/package.json index bfe9a5a2..4b0b5e0d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -51,6 +51,7 @@ "react-hook-form": "^7.50.1", "react-markdown": "^9.0.1", "react-masonry-css": "^1.0.16", + "react-select": "^5.8.0", "superjson": "^2.2.1", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", |
