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 ++++++++++++++------- .../dashboard/cleanups/TagDuplicationDetention.tsx | 11 +- apps/web/components/dashboard/tags/AllTagsView.tsx | 434 ++++++++++++------ .../components/dashboard/tags/MergeTagModal.tsx | 8 +- .../components/dashboard/tags/MultiTagSelector.tsx | 4 +- .../components/dashboard/tags/TagAutocomplete.tsx | 46 +- apps/web/components/dashboard/tags/TagPill.tsx | 4 +- apps/web/components/dashboard/tags/TagSelector.tsx | 57 --- 8 files changed, 676 insertions(+), 371 deletions(-) delete mode 100644 apps/web/components/dashboard/tags/TagSelector.tsx (limited to 'apps/web/components') 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} +
+
+ ); + })} +
+ )} +
+ + + + ); } diff --git a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx index afc70f24..52a9ab0c 100644 --- a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx +++ b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx @@ -200,15 +200,18 @@ function SuggestionRow({ export function TagDuplicationDetection() { const [expanded, setExpanded] = useState(false); - let { data: allTags } = api.tags.list.useQuery(undefined, { - refetchOnWindowFocus: false, - }); + let { data: allTags } = api.tags.list.useQuery( + {}, + { + refetchOnWindowFocus: false, + }, + ); const { suggestions, updateMergeInto, setSuggestions, deleteSuggestion } = useSuggestions(); useEffect(() => { - allTags = allTags ?? { tags: [] }; + allTags = allTags ?? { tags: [], nextCursor: null }; const sortedTags = allTags.tags.sort((a, b) => normalizeTag(a.name).localeCompare(normalizeTag(b.name)), ); diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx index 73d9a595..c21f9aac 100644 --- a/apps/web/components/dashboard/tags/AllTagsView.tsx +++ b/apps/web/components/dashboard/tags/AllTagsView.tsx @@ -13,20 +13,29 @@ import { CardTitle, } from "@/components/ui/card"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import InfoTooltip from "@/components/ui/info-tooltip"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import Spinner from "@/components/ui/spinner"; import { Toggle } from "@/components/ui/toggle"; import { toast } from "@/components/ui/use-toast"; import useBulkTagActionsStore from "@/lib/bulkTagActions"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; -import { ArrowDownAZ, Combine, Tag } from "lucide-react"; +import { ArrowDownAZ, ChevronDown, Combine, Search, Tag } from "lucide-react"; +import { parseAsStringEnum, useQueryState } from "nuqs"; import type { ZGetTagResponse, ZTagBasic } from "@karakeep/shared/types/tags"; -import { useDeleteUnusedTags } from "@karakeep/shared-react/hooks/tags"; +import { + useDeleteUnusedTags, + usePaginatedSearchTags, +} from "@karakeep/shared-react/hooks/tags"; +import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; import BulkTagAction from "./BulkTagAction"; import { CreateTagModal } from "./CreateTagModal"; @@ -70,104 +79,169 @@ function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) { ); } -const byUsageSorter = (a: ZGetTagResponse, b: ZGetTagResponse) => { - // Sort by name if the usage is the same to get a stable result - if (b.numBookmarks == a.numBookmarks) { - return byNameSorter(a, b); - } - return b.numBookmarks - a.numBookmarks; -}; -const byNameSorter = (a: ZGetTagResponse, b: ZGetTagResponse) => - a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); - -export default function AllTagsView({ - initialData, -}: { - initialData: ZGetTagResponse[]; -}) { +export default function AllTagsView() { const { t } = useTranslation(); + + const [searchQueryRaw, setSearchQuery] = useQueryState("q", { + defaultValue: "", + }); + const searchQuery = useDebounce(searchQueryRaw, 100); + const [sortBy, setSortBy] = useQueryState<"name" | "usage" | "relevance">( + "sort", + parseAsStringEnum(["name", "usage", "relevance"]) + .withOptions({ + clearOnDefault: true, + }) + .withDefault("usage"), + ); + const hasActiveSearch = searchQuery.length > 0; const [draggingEnabled, setDraggingEnabled] = React.useState(false); - const [sortByName, setSortByName] = React.useState(false); - const [isDialogOpen, setIsDialogOpen] = React.useState(false); const [selectedTag, setSelectedTag] = React.useState(null); + const isDialogOpen = !!selectedTag; const { setVisibleTagIds, isBulkEditEnabled } = useBulkTagActionsStore(); - const handleOpenDialog = (tag: ZTagBasic) => { + const handleOpenDialog = React.useCallback((tag: ZTagBasic) => { setSelectedTag(tag); - setIsDialogOpen(true); - }; - - function toggleSortByName(): void { - setSortByName(!sortByName); - } + }, []); function toggleDraggingEnabled(): void { setDraggingEnabled(!draggingEnabled); } - const { data } = api.tags.list.useQuery(undefined, { - initialData: { tags: initialData }, + const { + data: allHumanTagsRaw, + isFetching: isHumanTagsFetching, + isLoading: isHumanTagsLoading, + hasNextPage: hasNextPageHumanTags, + fetchNextPage: fetchNextPageHumanTags, + isFetchingNextPage: isFetchingNextPageHumanTags, + } = usePaginatedSearchTags({ + nameContains: searchQuery, + sortBy, + attachedBy: "human", + limit: 50, }); + const { + data: allAiTagsRaw, + isFetching: isAiTagsFetching, + isLoading: isAiTagsLoading, + hasNextPage: hasNextPageAiTags, + fetchNextPage: fetchNextPageAiTags, + isFetchingNextPage: isFetchingNextPageAiTags, + } = usePaginatedSearchTags({ + nameContains: searchQuery, + sortBy, + attachedBy: "ai", + limit: 50, + }); + + const { + data: allEmptyTagsRaw, + isFetching: isEmptyTagsFetching, + isLoading: isEmptyTagsLoading, + hasNextPage: hasNextPageEmptyTags, + fetchNextPage: fetchNextPageEmptyTags, + isFetchingNextPage: isFetchingNextPageEmptyTags, + } = usePaginatedSearchTags({ + nameContains: searchQuery, + sortBy, + attachedBy: "none", + limit: 50, + }); + + const isFetching = + isHumanTagsFetching || isAiTagsFetching || isEmptyTagsFetching; + + const { allHumanTags, allAiTags, allEmptyTags } = React.useMemo(() => { + return { + allHumanTags: allHumanTagsRaw?.tags ?? [], + allAiTags: allAiTagsRaw?.tags ?? [], + allEmptyTags: allEmptyTagsRaw?.tags ?? [], + }; + }, [allHumanTagsRaw, allAiTagsRaw, allEmptyTagsRaw]); + useEffect(() => { - const visibleTagIds = data.tags.map((tag) => tag.id); - setVisibleTagIds(visibleTagIds); + const allTags = [...allHumanTags, ...allAiTags, ...allEmptyTags]; + setVisibleTagIds(allTags.map((tag) => tag.id) ?? []); return () => { setVisibleTagIds([]); }; - }, [data.tags]); + }, [allHumanTags, allAiTags, allEmptyTags, setVisibleTagIds]); - // Sort tags by usage desc - const allTags = data.tags.sort(sortByName ? byNameSorter : byUsageSorter); + const sortLabels: Record = { + name: t("tags.sort_by_name"), + usage: t("tags.sort_by_usage"), + relevance: t("tags.sort_by_relevance"), + }; - const humanTags = allTags.filter( - (t) => (t.numBookmarksByAttachedType.human ?? 0) > 0, - ); - const aiTags = allTags.filter( - (t) => - (t.numBookmarksByAttachedType.human ?? 0) == 0 && - (t.numBookmarksByAttachedType.ai ?? 0) > 0, - ); - const emptyTags = allTags.filter((t) => t.numBookmarks === 0); + const tagsToPill = React.useMemo( + () => + ( + tags: ZGetTagResponse[], + bulkEditEnabled: boolean, + { + emptyMessage, + searchEmptyMessage, + }: { emptyMessage: string; searchEmptyMessage: string }, + isLoading: boolean, + ) => { + if (isLoading && tags.length === 0) { + return ( +
+ {Array.from({ length: 15 }).map((_, index) => ( + + ))} +
+ ); + } - const tagsToPill = (tags: typeof allTags, bulkEditEnabled: boolean) => { - let tagPill; - if (tags.length) { - tagPill = ( -
- {tags.map((t) => - bulkEditEnabled ? ( - - ) : ( - - ), - )} -
- ); - } else { - tagPill = ( -
- -

No custom tags yet

-
- ); - } - return tagPill; - }; + if (tags.length === 0) { + return ( +
+ +

+ {hasActiveSearch ? searchEmptyMessage : emptyMessage} +

+
+ ); + } + + return ( +
+ {tags.map((t) => + bulkEditEnabled ? ( + + ) : ( + + ), + )} + {isLoading && + Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); + }, + [draggingEnabled, handleOpenDialog, hasActiveSearch], + ); return (
{selectedTag && ( @@ -178,83 +252,173 @@ export default function AllTagsView({ if (!o) { setSelectedTag(null); } - setIsDialogOpen(o); }} /> )} -
- {t("tags.all_tags")} -
- - - - - {t("tags.drag_and_drop_merging")} - -

{t("tags.drag_and_drop_merging_info")}

-
-
- - {t("tags.sort_by_name")} - +
+
+ {t("tags.all_tags")} +
+ + + + + {t("tags.drag_and_drop_merging")} + +

{t("tags.drag_and_drop_merging_info")}

+
+
+
+
+
+
+
+ setSearchQuery(event.target.value)} + placeholder={t("common.search")} + aria-label={t("common.search")} + startIcon={} + endIcon={isFetching && } + autoComplete="off" + className="h-10" + /> +
+ + + + + + setSortBy(value as typeof sortBy)} + > + + {sortLabels["usage"]} + + + {sortLabels["name"]} + + + {sortLabels["relevance"]} + + + + +
{t("tags.your_tags")} - {humanTags.length} + {allHumanTags.length} {t("tags.your_tags_info")} - {tagsToPill(humanTags, isBulkEditEnabled)} + + {tagsToPill( + allHumanTags, + isBulkEditEnabled, + { + emptyMessage: t("tags.no_custom_tags"), + searchEmptyMessage: t("tags.no_tags_match_your_search"), + }, + isHumanTagsLoading, + )} + {hasNextPageHumanTags && ( + fetchNextPageHumanTags()} + loading={isFetchingNextPageHumanTags} + ignoreDemoMode + > + {t("actions.load_more")} + + )} + {t("tags.ai_tags")} - {aiTags.length} + {allAiTags.length} {t("tags.ai_tags_info")} - {tagsToPill(aiTags, isBulkEditEnabled)} + + {tagsToPill( + allAiTags, + isBulkEditEnabled, + { + emptyMessage: t("tags.no_ai_tags"), + searchEmptyMessage: t("tags.no_tags_match_your_search"), + }, + isAiTagsLoading, + )} + {hasNextPageAiTags && ( + fetchNextPageAiTags()} + loading={isFetchingNextPageAiTags} + ignoreDemoMode + > + {t("actions.load_more")} + + )} + - {t("tags.unused_tags")} + + {t("tags.unused_tags")} + {allEmptyTags.length} + {t("tags.unused_tags_info")} - - -
- - - - {emptyTags.length > 0 && ( - - )} -
- - {tagsToPill(emptyTags, isBulkEditEnabled)} - -
+ + {tagsToPill( + allEmptyTags, + isBulkEditEnabled, + { + emptyMessage: t("tags.no_unused_tags"), + searchEmptyMessage: t("tags.no_unused_tags_match_your_search"), + }, + isEmptyTagsLoading, + )} + {hasNextPageEmptyTags && ( + fetchNextPageEmptyTags()} + loading={isFetchingNextPageEmptyTags} + ignoreDemoMode + > + {t("actions.load_more")} + + )} + {allEmptyTags.length > 0 && ( + + )}
diff --git a/apps/web/components/dashboard/tags/MergeTagModal.tsx b/apps/web/components/dashboard/tags/MergeTagModal.tsx index c3ae1e57..84dcd478 100644 --- a/apps/web/components/dashboard/tags/MergeTagModal.tsx +++ b/apps/web/components/dashboard/tags/MergeTagModal.tsx @@ -25,7 +25,7 @@ import { z } from "zod"; import { useMergeTag } from "@karakeep/shared-react/hooks/tags"; -import { TagSelector } from "./TagSelector"; +import { TagAutocomplete } from "./TagAutocomplete"; export function MergeTagModal({ open, @@ -119,10 +119,10 @@ export function MergeTagModal({ return ( - diff --git a/apps/web/components/dashboard/tags/MultiTagSelector.tsx b/apps/web/components/dashboard/tags/MultiTagSelector.tsx index 096c4566..c7511eec 100644 --- a/apps/web/components/dashboard/tags/MultiTagSelector.tsx +++ b/apps/web/components/dashboard/tags/MultiTagSelector.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"; import { Check } from "lucide-react"; import { useTheme } from "next-themes"; -export function MultiTagSelector({ +export const MultiTagSelector = React.memo(function MultiTagSelector({ id, name, count, @@ -58,4 +58,4 @@ export function MultiTagSelector({ ); return pill; -} +}); diff --git a/apps/web/components/dashboard/tags/TagAutocomplete.tsx b/apps/web/components/dashboard/tags/TagAutocomplete.tsx index 23054bc7..8164dc81 100644 --- a/apps/web/components/dashboard/tags/TagAutocomplete.tsx +++ b/apps/web/components/dashboard/tags/TagAutocomplete.tsx @@ -14,10 +14,13 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import LoadingSpinner from "@/components/ui/spinner"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { Check, ChevronsUpDown, X } from "lucide-react"; +import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; +import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { api } from "@karakeep/shared-react/trpc"; + interface TagAutocompleteProps { tagId: string; onChange?: (value: string) => void; @@ -29,17 +32,28 @@ export function TagAutocomplete({ onChange, className, }: TagAutocompleteProps) { - const { data: tags, isPending } = api.tags.list.useQuery(undefined, { - select: (data) => data.tags, - }); - const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const searchQueryDebounced = useDebounce(searchQuery, 500); - // Filter tags based on search query - const filteredTags = (tags ?? []) - .filter((tag) => tag.name.toLowerCase().includes(searchQuery.toLowerCase())) - .slice(0, 10); // Only show first 10 matches for performance + const { data: tags, isLoading } = useTagAutocomplete({ + nameContains: searchQueryDebounced, + select: (data) => data.tags, + }); + + const { data: selectedTag, isLoading: isSelectedTagLoading } = + api.tags.get.useQuery( + { + tagId, + }, + { + select: ({ id, name }) => ({ + id, + name, + }), + enabled: !!tagId, + }, + ); const handleSelect = (currentValue: string) => { setOpen(false); @@ -50,12 +64,7 @@ export function TagAutocomplete({ onChange?.(""); }; - const selectedTag = React.useMemo(() => { - if (!tagId) return null; - return tags?.find((t) => t.id === tagId) ?? null; - }, [tags, tagId]); - - if (isPending) { + if (!tags || isLoading || isSelectedTagLoading) { return ; } @@ -96,7 +105,7 @@ export function TagAutocomplete({ No tags found. - {filteredTags.map((tag) => ( + {tags.map((tag) => ( ))} - {searchQuery && filteredTags.length >= 10 && ( -
- Showing first 10 results. Keep typing to refine your search. -
- )}
diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx index 2da97b2e..65a42e08 100644 --- a/apps/web/components/dashboard/tags/TagPill.tsx +++ b/apps/web/components/dashboard/tags/TagPill.tsx @@ -9,7 +9,7 @@ import Draggable from "react-draggable"; import { useMergeTag } from "@karakeep/shared-react/hooks/tags"; -export function TagPill({ +export const TagPill = React.memo(function TagPill({ id, name, count, @@ -118,4 +118,4 @@ export function TagPill({ {pill} ); -} +}); diff --git a/apps/web/components/dashboard/tags/TagSelector.tsx b/apps/web/components/dashboard/tags/TagSelector.tsx deleted file mode 100644 index 27213b8a..00000000 --- a/apps/web/components/dashboard/tags/TagSelector.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import LoadingSpinner from "@/components/ui/spinner"; -import { api } from "@/lib/trpc"; -import { cn } from "@/lib/utils"; - -export function TagSelector({ - value, - onChange, - placeholder = "Select a tag", - className, -}: { - value?: string | null; - onChange: (value: string) => void; - placeholder?: string; - className?: string; -}) { - const { data: allTags, isPending } = api.tags.list.useQuery(undefined, { - select: (data) => ({ - tags: data.tags.sort((a, b) => a.name.localeCompare(b.name)), - }), - }); - - if (isPending || !allTags) { - return ; - } - - return ( - - ); -} -- cgit v1.2.3-70-g09d2