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 ( - - - - - - - updateBookmarkMutator.mutate({ - bookmarkId: linkId, - favourited: !bookmark.favourited, - }) - } - > - - {bookmark.favourited ? "Un-favourite" : "Favourite"} - - - updateBookmarkMutator.mutate({ - bookmarkId: linkId, - archived: !bookmark.archived, - }) - } - > - - {bookmark.archived ? "Un-archive" : "Archive"} - - - crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) - } - > - - Refresh - - - deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id }) - } - > - - Delete - - - + <> + {tagModal} + + + + + + + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + favourited: !bookmark.favourited, + }) + } + > + + {bookmark.favourited ? "Un-favourite" : "Favourite"} + + + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + archived: !bookmark.archived, + }) + } + > + + {bookmark.archived ? "Un-archive" : "Archive"} + + setTagModalIsOpen(true)}> + + Edit Tags + + + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + + Refresh + + + deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + + Delete + + + + ); } 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) => { + if (e.key === "Enter") { + addTag(e.currentTarget.value); + e.currentTarget.value = ""; + } + }; + return ( + + ); +} + +function TagPill({ + tag, + deleteCB, +}: { + tag: { attachedBy: ZAttachedByEnum; id?: string; name: string }; + deleteCB: () => void; +}) { + const isAttachedByAI = tag.attachedBy == "ai"; + return ( +
+ {isAttachedByAI && } +

{tag.name}

+ +
+ ); +} + +function TagEditor({ + tags, + setTags, +}: { + tags: Map; + setTags: ( + cb: (m: Map) => Map, + ) => void; +}) { + return ( +
+ {[...tags.values()].map((t) => ( + + setTags((m) => { + const newMap = new Map(m); + newMap.delete(t.name); + return newMap; + }) + } + /> + ))} +
+ { + 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; + }); + }} + /> +
+
+ ); +} + +export default function TagModal({ + bookmark, + open, + setOpen, +}: { + bookmark: ZBookmark; + open: boolean; + setOpen: (open: boolean) => void; +}) { + const [tags, setTags] = useState(() => { + const m = new Map(); + 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 ( + + + + Edit Tags + + + + + + + + + + Save + + + + + ); +} + +export function useTagModel(bookmark: ZBookmark) { + const [open, setOpen] = useState(false); + + return [ + open, + 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; export const zBookmarkTagSchema = z.object({ id: z.string(), name: z.string(), + attachedBy: zAttachedByEnumSchema, }); export type ZBookmarkTags = z.infer; 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"