diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-09-28 11:03:48 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-09-28 11:03:48 +0100 |
| commit | 62f7d900c52784ff05d933b52379e5455ea6bd00 (patch) | |
| tree | 2702d74c96576447974af84850f3ba6b66beeeb4 /apps/web/components/dashboard/bookmarks | |
| parent | 9fe09bfa9021c8d85d2d9aef591936101cab19f6 (diff) | |
| download | karakeep-62f7d900c52784ff05d933b52379e5455ea6bd00.tar.zst | |
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
Diffstat (limited to 'apps/web/components/dashboard/bookmarks')
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/TagsEditor.tsx | 483 |
1 files changed, 337 insertions, 146 deletions
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<HTMLInputElement>(null); + const containerRef = React.useRef<HTMLDivElement>(null); + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_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<DisplayOption[]>(() => { + 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<EditableTag>, + 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<HTMLInputElement>) => { + 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 ( - <CreateableSelect - isDisabled={demoMode} - onChange={onChange} - options={ - existingTags?.tags.map((t) => ({ - 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 }) => ( - <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-accent", - )} + <div ref={containerRef}> + <Popover open={open && !isDisabled} onOpenChange={handleOpenChange}> + <Command shouldFilter={false}> + <PopoverTrigger asChild> + <div + className={cn( + "relative flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2", + isDisabled && "cursor-not-allowed opacity-50", + )} + > + {optimisticTags.length > 0 && ( + <> + {optimisticTags.map((tag) => ( + <div + key={tag.id} + className={cn( + "flex min-h-8 space-x-1 rounded px-2", + tag.attachedBy == "ai" + ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white" + : "bg-accent", + )} + > + <div className="m-auto flex gap-2"> + {tag.attachedBy === "ai" && ( + <Sparkles className="m-auto size-4" /> + )} + {tag.name} + {!isDisabled && ( + <button + type="button" + className="rounded-full outline-none ring-offset-background focus:ring-1 focus:ring-ring focus:ring-offset-2" + onClick={(e) => { + e.stopPropagation(); + onChange({ + action: "remove-value", + id: tag.id, + name: tag.name, + }); + }} + > + <X className="h-3 w-3" /> + <span className="sr-only">Remove {tag.name}</span> + </button> + )} + </div> + </div> + ))} + </> + )} + <CommandPrimitive.Input + ref={inputRef} + value={inputValue} + onKeyDown={handleKeyDown} + onValueChange={(v) => setInputValue(v)} + className="bg-transparent outline-none placeholder:text-muted-foreground" + style={{ width: `${Math.max(inputValue.length, 1)}ch` }} + disabled={isDisabled} + /> + {isExistingTagsLoading && ( + <div className="absolute bottom-2 right-2"> + <Loader2 className="h-4 w-4 animate-spin opacity-50" /> + </div> + )} + </div> + </PopoverTrigger> + <PopoverContent + className="w-[--radix-popover-trigger-width] p-0" + align="start" > - {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> - ), - DropdownIndicator: () => <span />, - IndicatorSeparator: () => <span />, - }} - 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", - }} - /> + <CommandList className="max-h-64"> + {displayedOptions.length === 0 ? ( + <CommandEmpty> + {trimmedInputValue ? ( + <div className="flex items-center justify-between px-2 py-1.5"> + <span>Create "{trimmedInputValue}"</span> + <Button + variant="ghost" + size="sm" + onClick={createTag} + className="h-auto p-1" + > + <Plus className="h-4 w-4" /> + </Button> + </div> + ) : ( + "No tags found." + )} + </CommandEmpty> + ) : ( + <CommandGroup> + {displayedOptions.map((option) => { + const isSelected = selectedValues.includes(option.id); + return ( + <CommandItem + key={ + option.isCreateOption + ? `create-${option.name}` + : option.id + } + value={option.label} + onSelect={() => handleSelect(option)} + > + <div className="flex w-full items-center gap-2"> + {option.isCreateOption ? ( + <Plus className="h-4 w-4" /> + ) : ( + <Check + className={cn( + "h-4 w-4", + isSelected ? "opacity-100" : "opacity-0", + )} + /> + )} + <span>{option.name}</span> + </div> + </CommandItem> + ); + })} + </CommandGroup> + )} + </CommandList> + </PopoverContent> + </Command> + </Popover> + </div> ); } |
