diff options
| author | MohamedBassem <me@mbassem.com> | 2024-02-26 12:47:36 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-02-26 12:47:36 +0000 |
| commit | 3fe20dda157cbae282f55d6afb8e8f99e795945a (patch) | |
| tree | 59a8ef1e5ab98d75d83c5cd23ea6c12af9615ca0 /packages | |
| parent | e234d3535c363664902dffe89a2c61ddbc037da4 (diff) | |
| download | karakeep-3fe20dda157cbae282f55d6afb8e8f99e795945a.tar.zst | |
feature: Add support for adding/removing tags
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/db/schema.ts | 2 | ||||
| -rw-r--r-- | packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx | 115 | ||||
| -rw-r--r-- | packages/web/app/dashboard/bookmarks/components/TagModal.tsx | 207 | ||||
| -rw-r--r-- | packages/web/lib/types/api/tags.ts | 3 | ||||
| -rw-r--r-- | packages/web/server/api/routers/bookmarks.ts | 89 | ||||
| -rw-r--r-- | packages/web/tsconfig.json | 1 |
6 files changed, 365 insertions, 52 deletions
diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 0a30cf59..94467c56 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -156,7 +156,7 @@ export const tagsOnBookmarks = sqliteTable( attachedAt: integer("attachedAt", { mode: "timestamp" }).$defaultFn( () => new Date(), ), - attachedBy: text("attachedBy", { enum: ["ai", "human"] }), + attachedBy: text("attachedBy", { enum: ["ai", "human"] }).notNull(), }, (tb) => ({ pk: primaryKey({ columns: [tb.bookmarkId, tb.tagId] }), diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx index a72478c1..6c1133fb 100644 --- a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx +++ b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx @@ -10,12 +10,22 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Archive, MoreHorizontal, RotateCw, Star, Trash2 } from "lucide-react"; +import { + Archive, + MoreHorizontal, + RotateCw, + Star, + Tags, + Trash2, +} from "lucide-react"; +import { useTagModel } from "./TagModal"; export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { toast } = useToast(); const linkId = bookmark.id; + const [_, setTagModalIsOpen, tagModal] = useTagModel(bookmark); + const invalidateBookmarksCache = api.useUtils().bookmarks.invalidate; const onError = () => { @@ -59,53 +69,60 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }); return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost"> - <MoreHorizontal /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent className="w-fit"> - <DropdownMenuItem - onClick={() => - updateBookmarkMutator.mutate({ - bookmarkId: linkId, - favourited: !bookmark.favourited, - }) - } - > - <Star className="mr-2 size-4" /> - <span>{bookmark.favourited ? "Un-favourite" : "Favourite"}</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - updateBookmarkMutator.mutate({ - bookmarkId: linkId, - archived: !bookmark.archived, - }) - } - > - <Archive className="mr-2 size-4" /> - <span>{bookmark.archived ? "Un-archive" : "Archive"}</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) - } - > - <RotateCw className="mr-2 size-4" /> - <span>Refresh</span> - </DropdownMenuItem> - <DropdownMenuItem - className="text-destructive" - onClick={() => - deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id }) - } - > - <Trash2 className="mr-2 size-4" /> - <span>Delete</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + <> + {tagModal} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost"> + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-fit"> + <DropdownMenuItem + onClick={() => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + favourited: !bookmark.favourited, + }) + } + > + <Star className="mr-2 size-4" /> + <span>{bookmark.favourited ? "Un-favourite" : "Favourite"}</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + archived: !bookmark.archived, + }) + } + > + <Archive className="mr-2 size-4" /> + <span>{bookmark.archived ? "Un-archive" : "Archive"}</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setTagModalIsOpen(true)}> + <Tags className="mr-2 size-4" /> + <span>Edit Tags</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + <RotateCw className="mr-2 size-4" /> + <span>Refresh</span> + </DropdownMenuItem> + <DropdownMenuItem + className="text-destructive" + onClick={() => + deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + <Trash2 className="mr-2 size-4" /> + <span>Delete</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </> ); } diff --git a/packages/web/app/dashboard/bookmarks/components/TagModal.tsx b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx new file mode 100644 index 00000000..c1618541 --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/components/TagModal.tsx @@ -0,0 +1,207 @@ +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ZBookmark } from "@/lib/types/api/bookmarks"; +import { ZAttachedByEnum } from "@/lib/types/api/tags"; +import { cn } from "@/lib/utils"; +import { Sparkles, X } from "lucide-react"; +import { useState, KeyboardEvent } from "react"; + +type 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 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> + ); +} + +function TagEditor({ + tags, + setTags, +}: { + tags: Map<string, EditableTag>; + setTags: ( + cb: (m: Map<string, EditableTag>) => Map<string, EditableTag>, + ) => void; +}) { + return ( + <div className="mt-4 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); + 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 }); + return newMap; + }); + }} + /> + </div> + </div> + ); +} + +export default function TagModal({ + bookmark, + open, + setOpen, +}: { + bookmark: ZBookmark; + open: boolean; + setOpen: (open: boolean) => void; +}) { + const [tags, setTags] = useState(() => { + 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 }); + } + return m; + }); + + const bookmarkInvalidationFunction = + api.useUtils().bookmarks.getBookmark.invalidate; + + const { mutate, isPending } = api.bookmarks.updateTags.useMutation({ + onSuccess: () => { + toast({ + description: "Tags has been updated!", + }); + bookmarkInvalidationFunction({ id: bookmark.id }); + }, + onError: () => { + toast({ + variant: "destructive", + title: "Something went wrong", + description: "There was a problem with your request.", + }); + }, + }); + + const onSaveButton = () => { + const exitingTags = new Set(bookmark.tags.map((t) => t.name)); + + const attach = []; + const detach = []; + for (const t of tags.values()) { + if (!exitingTags.has(t.name)) { + attach.push({ tag: t.name }); + } + } + for (const t of bookmark.tags) { + if (!tags.has(t.name)) { + detach.push(t.id); + } + } + mutate({ + bookmarkId: bookmark.id, + attach, + detach, + }); + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>Edit Tags</DialogTitle> + <DialogDescription> + <TagEditor tags={tags} setTags={setTags} /> + </DialogDescription> + </DialogHeader> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="button" + loading={isPending} + onClick={onSaveButton} + > + Save + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export function useTagModel(bookmark: ZBookmark) { + const [open, setOpen] = useState(false); + + return [ + open, + setOpen, + <TagModal + key={bookmark.id} + bookmark={bookmark} + open={open} + setOpen={setOpen} + />, + ] as const; +} diff --git a/packages/web/lib/types/api/tags.ts b/packages/web/lib/types/api/tags.ts index bcd16f5b..7a99dad4 100644 --- a/packages/web/lib/types/api/tags.ts +++ b/packages/web/lib/types/api/tags.ts @@ -1,7 +1,10 @@ import { z } from "zod"; +export const zAttachedByEnumSchema = z.enum(["ai", "human"]); +export type ZAttachedByEnum = z.infer<typeof zAttachedByEnumSchema>; export const zBookmarkTagSchema = z.object({ id: z.string(), name: z.string(), + attachedBy: zAttachedByEnumSchema, }); export type ZBookmarkTags = z.infer<typeof zBookmarkTagSchema>; diff --git a/packages/web/server/api/routers/bookmarks.ts b/packages/web/server/api/routers/bookmarks.ts index 2af81d27..3070eac3 100644 --- a/packages/web/server/api/routers/bookmarks.ts +++ b/packages/web/server/api/routers/bookmarks.ts @@ -11,7 +11,12 @@ import { zUpdateBookmarksRequestSchema, } from "@/lib/types/api/bookmarks"; import { db } from "@hoarder/db"; -import { bookmarkLinks, bookmarks } from "@hoarder/db/schema"; +import { + bookmarkLinks, + bookmarkTags, + bookmarks, + tagsOnBookmarks, +} from "@hoarder/db/schema"; import { LinkCrawlerQueue } from "@hoarder/shared/queues"; import { TRPCError, experimental_trpcMiddleware } from "@trpc/server"; import { User } from "next-auth"; @@ -74,7 +79,10 @@ function toZodSchema( } return { - tags: tagsOnBookmarks.map((t) => t.tag), + tags: tagsOnBookmarks.map((t) => ({ + attachedBy: t.attachedBy, + ...t.tag, + })), content, ...rest, }; @@ -234,4 +242,81 @@ export const bookmarksAppRouter = router({ return { bookmarks: results.map(toZodSchema) }; }), + + updateTags: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + attach: z.array( + z.object({ + tagId: z.string().optional(), // If the tag already exists and we know its id + tag: z.string(), + }), + ), + detach: z.array(z.string()), + }), + ) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + await db.transaction(async (tx) => { + // Detaches + if (input.detach.length > 0) { + await db + .delete(tagsOnBookmarks) + .where( + and( + eq(tagsOnBookmarks.bookmarkId, input.bookmarkId), + inArray(tagsOnBookmarks.tagId, input.detach), + ), + ); + } + + if (input.attach.length == 0) { + return; + } + + // New Tags + const toBeCreatedTags = input.attach + .filter((i) => i.tagId === undefined) + .map((i) => ({ + name: i.tag, + userId: ctx.user.id, + })); + + if (toBeCreatedTags.length > 0) { + await db + .insert(bookmarkTags) + .values(toBeCreatedTags) + .onConflictDoNothing() + .returning(); + } + + const allIds = ( + await db.query.bookmarkTags.findMany({ + where: and( + eq(bookmarkTags.userId, ctx.user.id), + inArray( + bookmarkTags.name, + input.attach.map((t) => t.tag), + ), + ), + columns: { + id: true, + }, + }) + ).map((t) => t.id); + + await db + .insert(tagsOnBookmarks) + .values( + allIds.map((i) => ({ + tagId: i as string, + bookmarkId: input.bookmarkId, + attachedBy: "human" as const, + userId: ctx.user.id, + })), + ) + .onConflictDoNothing(); + }); + }), }); diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index a25dbc14..ecbd5643 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -13,6 +13,7 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, + "target": "ES6", "plugins": [ { "name": "next" |
