From 62f7d900c52784ff05d933b52379e5455ea6bd00 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 28 Sep 2025 11:03:48 +0100 Subject: feat: Add tag search and pagination (#1987) * feat: Add tag search and use in the homepage * use paginated query in the all tags view * wire the load more buttons * add skeleton to all tags page * fix attachedby aggregation * fix loading states * fix hasNextPage * use action buttons for load more buttons * migrate the tags auto complete to the search api * Migrate the tags editor to the new search API * Replace tag merging dialog with tag auto completion * Merge both search and list APIs * fix tags.list * add some tests for the endpoint * add relevance based sorting * change cursor * update the REST API * fix review comments * more fixes * fix lockfile * i18n * fix visible tags --- .../components/dashboard/bookmarks/TagsEditor.tsx | 483 ++++++++++++++------- 1 file changed, 337 insertions(+), 146 deletions(-) (limited to 'apps/web/components/dashboard/bookmarks') diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx index f80ba963..7c6393c3 100644 --- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx @@ -1,21 +1,25 @@ -import type { ActionMeta } from "react-select"; -import { useState } from "react"; +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { useClientConfig } from "@/lib/clientConfig"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; -import { Sparkles } from "lucide-react"; -import CreateableSelect from "react-select/creatable"; - -import type { - ZAttachedByEnum, - ZBookmarkTags, -} from "@karakeep/shared/types/tags"; - -interface EditableTag { - attachedBy: ZAttachedByEnum; - value?: string; - label: string; -} +import { keepPreviousData } from "@tanstack/react-query"; +import { Command as CommandPrimitive } from "cmdk"; +import { Check, Loader2, Plus, Sparkles, X } from "lucide-react"; + +import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; export function TagsEditor({ tags: _tags, @@ -27,167 +31,354 @@ export function TagsEditor({ onDetach: (tag: { tagName: string; tagId: string }) => void; }) { const demoMode = !!useClientConfig().demoMode; - + const isDisabled = demoMode; + const inputRef = React.useRef(null); + const containerRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); const [optimisticTags, setOptimisticTags] = useState(_tags); + const tempIdCounter = React.useRef(0); + + const generateTempId = React.useCallback(() => { + tempIdCounter.current += 1; + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return `temp-${crypto.randomUUID()}`; + } + + return `temp-${Date.now()}-${tempIdCounter.current}`; + }, []); - const { data: existingTags, isLoading: isExistingTagsLoading } = - api.tags.list.useQuery(undefined, { - select: (data) => ({ - tags: data.tags.sort((a, b) => a.name.localeCompare(b.name)), - }), + React.useEffect(() => { + setOptimisticTags((prev) => { + let results = prev; + for (const tag of _tags) { + const idx = results.findIndex((t) => t.name === tag.name); + if (idx == -1) { + results.push(tag); + continue; + } + if (results[idx].id.startsWith("temp-")) { + results[idx] = tag; + continue; + } + } + return results; }); + }, [_tags]); + + const { data: filteredOptions, isLoading: isExistingTagsLoading } = + api.tags.list.useQuery( + { + nameContains: inputValue, + limit: 50, + sortBy: inputValue.length > 0 ? "relevance" : "usage", + }, + { + select: (data) => + data.tags.map((t) => ({ + id: t.id, + name: t.name, + attachedBy: + (t.numBookmarksByAttachedType.human ?? 0) > 0 + ? ("human" as const) + : ("ai" as const), + })), + placeholderData: keepPreviousData, + gcTime: inputValue.length > 0 ? 60_000 : 3_600_000, + }, + ); + + const selectedValues = optimisticTags.map((tag) => tag.id); + + // Add "create new" option if input doesn't match any existing option + const trimmedInputValue = inputValue.trim(); + + interface DisplayOption { + id: string; + name: string; + label: string; + attachedBy: "human" | "ai"; + isCreateOption?: boolean; + } + + const displayedOptions = React.useMemo(() => { + if (!filteredOptions) return []; + + const baseOptions = filteredOptions.map((option) => ({ + ...option, + label: option.name, + })); + + if (!trimmedInputValue) { + return baseOptions; + } + + const exactMatch = baseOptions.some( + (opt) => opt.name.toLowerCase() === trimmedInputValue.toLowerCase(), + ); + + if (!exactMatch) { + return [ + { + id: "create-new", + name: trimmedInputValue, + label: `Create "${trimmedInputValue}"`, + attachedBy: "human" as const, + isCreateOption: true, + }, + ...baseOptions, + ]; + } + + return baseOptions; + }, [filteredOptions, trimmedInputValue]); const onChange = ( - _option: readonly EditableTag[], - actionMeta: ActionMeta, + actionMeta: + | { action: "create-option"; name: string } + | { action: "select-option"; id: string; name: string } + | { + action: "remove-value"; + id: string; + name: string; + }, ) => { switch (actionMeta.action) { - case "pop-value": case "remove-value": { - if (actionMeta.removedValue.value) { - setOptimisticTags((prev) => - prev.filter((t) => t.id != actionMeta.removedValue.value), - ); - onDetach({ - tagId: actionMeta.removedValue.value, - tagName: actionMeta.removedValue.label, - }); - } + setOptimisticTags((prev) => prev.filter((t) => t.id != actionMeta.id)); + onDetach({ + tagId: actionMeta.id, + tagName: actionMeta.name, + }); break; } case "create-option": { + const tempId = generateTempId(); setOptimisticTags((prev) => [ ...prev, { - id: "", - name: actionMeta.option.label, + id: tempId, + name: actionMeta.name, attachedBy: "human" as const, }, ]); - onAttach({ tagName: actionMeta.option.label }); + onAttach({ tagName: actionMeta.name }); break; } case "select-option": { - if (actionMeta.option) { - setOptimisticTags((prev) => [ + setOptimisticTags((prev) => { + if (prev.some((tag) => tag.id === actionMeta.id)) { + return prev; + } + + return [ ...prev, { - id: actionMeta.option?.value ?? "", - name: actionMeta.option!.label, + id: actionMeta.id, + name: actionMeta.name, attachedBy: "human" as const, }, - ]); - onAttach({ - tagName: actionMeta.option.label, - tagId: actionMeta.option?.value, - }); - } + ]; + }); + onAttach({ + tagName: actionMeta.name, + tagId: actionMeta.id, + }); break; } } }; + const createTag = () => { + if (!inputValue.trim()) return; + onChange({ action: "create-option", name: inputValue.trim() }); + setInputValue(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + } else if ( + e.key === "Backspace" && + !inputValue && + optimisticTags.length > 0 + ) { + const lastTag = optimisticTags.slice(-1)[0]; + onChange({ + action: "remove-value", + id: lastTag.id, + name: lastTag.name, + }); + } + }; + + const handleSelect = (option: DisplayOption) => { + if (option.isCreateOption) { + onChange({ action: "create-option", name: option.name }); + setInputValue(""); + inputRef.current?.focus(); + return; + } + + // If already selected, remove it + if (selectedValues.includes(option.id)) { + onChange({ + action: "remove-value", + id: option.id, + name: option.name, + }); + } else { + // Add the new tag + onChange({ + action: "select-option", + id: option.id, + name: option.name, + }); + } + + // Reset input and keep focus + setInputValue(""); + inputRef.current?.focus(); + }; + + const handleOpenChange = (open: boolean) => { + setOpen(open); + if (open) { + // Focus the input + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + }; + return ( - ({ - label: t.name, - value: t.id, - attachedBy: "human" as const, - })) ?? [] - } - value={optimisticTags.slice().map((t) => ({ - label: t.name, - value: t.id, - attachedBy: t.attachedBy, - }))} - isMulti - closeMenuOnSelect={false} - isClearable={false} - isLoading={isExistingTagsLoading} - theme={(theme) => ({ - ...theme, - // This color scheme doesn't support disabled options. - colors: { - ...theme.colors, - primary: "hsl(var(--accent))", - primary50: "hsl(var(--accent))", - primary75: "hsl(var(--accent))", - primary25: "hsl(var(--accent))", - }, - })} - styles={{ - multiValueRemove: () => ({ - backgroundColor: "transparent", - }), - valueContainer: (styles) => ({ - ...styles, - padding: "0.5rem", - maxHeight: "14rem", - overflowY: "auto", - scrollbarWidth: "thin", - }), - container: (styles) => ({ - ...styles, - width: "100%", - }), - control: (styles) => ({ - ...styles, - overflow: "hidden", - backgroundColor: "hsl(var(--background))", - borderColor: "hsl(var(--border))", - ":hover": { - borderColor: "hsl(var(--border))", - }, - }), - input: (styles) => ({ - ...styles, - color: "rgb(156 163 175)", - }), - menu: (styles) => ({ - ...styles, - overflow: "hidden", - color: "rgb(156 163 175)", - }), - placeholder: (styles) => ({ - ...styles, - color: "hsl(var(--muted-foreground))", - }), - }} - components={{ - MultiValueContainer: ({ children, data }) => ( -
+ + + +
+ {optimisticTags.length > 0 && ( + <> + {optimisticTags.map((tag) => ( +
+
+ {tag.attachedBy === "ai" && ( + + )} + {tag.name} + {!isDisabled && ( + + )} +
+
+ ))} + + )} + setInputValue(v)} + className="bg-transparent outline-none placeholder:text-muted-foreground" + style={{ width: `${Math.max(inputValue.length, 1)}ch` }} + disabled={isDisabled} + /> + {isExistingTagsLoading && ( +
+ +
+ )} +
+
+ - {children} -
- ), - MultiValueLabel: ({ children, data }) => ( -
- {(data as { attachedBy: string }).attachedBy == "ai" && ( - - )} - {children} -
- ), - DropdownIndicator: () => , - IndicatorSeparator: () => , - }} - classNames={{ - multiValueRemove: () => "my-auto", - valueContainer: () => "gap-2 bg-background text-sm", - menu: () => "dark:text-gray-300", - menuList: () => "bg-background text-sm", - option: () => "text-red-500", - input: () => "dark:text-gray-300", - }} - /> + + {displayedOptions.length === 0 ? ( + + {trimmedInputValue ? ( +
+ Create "{trimmedInputValue}" + +
+ ) : ( + "No tags found." + )} +
+ ) : ( + + {displayedOptions.map((option) => { + const isSelected = selectedValues.includes(option.id); + return ( + handleSelect(option)} + > +
+ {option.isCreateOption ? ( + + ) : ( + + )} + {option.name} +
+
+ ); + })} +
+ )} +
+ + + + ); } -- cgit v1.2.3-70-g09d2