aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/dashboard/tags/page.tsx16
-rw-r--r--apps/web/components/dashboard/bookmarks/TagsEditor.tsx233
-rw-r--r--apps/web/package.json1
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",