aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/db/schema.ts2
-rw-r--r--packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx115
-rw-r--r--packages/web/app/dashboard/bookmarks/components/TagModal.tsx207
-rw-r--r--packages/web/lib/types/api/tags.ts3
-rw-r--r--packages/web/server/api/routers/bookmarks.ts89
-rw-r--r--packages/web/tsconfig.json1
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"