Skip to content

Commit

Permalink
feature: Add support for adding/removing tags
Browse files Browse the repository at this point in the history
  • Loading branch information
MohamedBassem committed Feb 26, 2024
1 parent e234d35 commit 3fe20dd
Show file tree
Hide file tree
Showing 6 changed files with 365 additions and 52 deletions.
2 changes: 1 addition & 1 deletion packages/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] }),
Expand Down
115 changes: 66 additions & 49 deletions packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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>
</>
);
}
207 changes: 207 additions & 0 deletions packages/web/app/dashboard/bookmarks/components/TagModal.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/web/lib/types/api/tags.ts
Original file line number Diff line number Diff line change
@@ -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>;
Loading

0 comments on commit 3fe20dd

Please sign in to comment.