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 | |
| 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')
8 files changed, 676 insertions, 371 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> ); } 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<ZTagBasic | null>(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<typeof sortBy, string> = { + 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 ( + <div className="flex flex-wrap gap-3"> + {Array.from({ length: 15 }).map((_, index) => ( + <Skeleton key={`tag-skeleton-${index}`} className="h-9 w-24" /> + ))} + </div> + ); + } - const tagsToPill = (tags: typeof allTags, bulkEditEnabled: boolean) => { - let tagPill; - if (tags.length) { - tagPill = ( - <div className="flex flex-wrap gap-3"> - {tags.map((t) => - bulkEditEnabled ? ( - <MultiTagSelector - key={t.id} - id={t.id} - name={t.name} - count={t.numBookmarks} - /> - ) : ( - <TagPill - key={t.id} - id={t.id} - name={t.name} - count={t.numBookmarks} - isDraggable={draggingEnabled} - onOpenDialog={handleOpenDialog} - /> - ), - )} - </div> - ); - } else { - tagPill = ( - <div className="py-8 text-center"> - <Tag className="mx-auto mb-4 h-12 w-12 text-gray-300" /> - <p className="mb-4 text-gray-500">No custom tags yet</p> - </div> - ); - } - return tagPill; - }; + if (tags.length === 0) { + return ( + <div className="py-8 text-center"> + <Tag className="mx-auto mb-4 h-12 w-12 text-gray-300" /> + <p className="mb-4 text-gray-500"> + {hasActiveSearch ? searchEmptyMessage : emptyMessage} + </p> + </div> + ); + } + + return ( + <div className="flex flex-wrap gap-3"> + {tags.map((t) => + bulkEditEnabled ? ( + <MultiTagSelector + key={t.id} + id={t.id} + name={t.name} + count={t.numBookmarks} + /> + ) : ( + <TagPill + key={t.id} + id={t.id} + name={t.name} + count={t.numBookmarks} + isDraggable={draggingEnabled} + onOpenDialog={handleOpenDialog} + /> + ), + )} + {isLoading && + Array.from({ length: 3 }).map((_, index) => ( + <Skeleton + key={`tag-skeleton-loading-${index}`} + className="h-9 w-24" + /> + ))} + </div> + ); + }, + [draggingEnabled, handleOpenDialog, hasActiveSearch], + ); return ( <div className="flex flex-col gap-4"> {selectedTag && ( @@ -178,83 +252,173 @@ export default function AllTagsView({ if (!o) { setSelectedTag(null); } - setIsDialogOpen(o); }} /> )} - <div className="flex justify-between gap-x-2"> - <span className="text-2xl">{t("tags.all_tags")}</span> - <div className="flex gap-x-2"> - <CreateTagModal /> - <BulkTagAction /> - <Toggle - variant="outline" - className="bg-background" - aria-label="Toggle bold" - pressed={draggingEnabled} - onPressedChange={toggleDraggingEnabled} - disabled={isBulkEditEnabled} - > - <Combine className="mr-2 size-4" /> - {t("tags.drag_and_drop_merging")} - <InfoTooltip size={15} className="my-auto ml-2" variant="explain"> - <p>{t("tags.drag_and_drop_merging_info")}</p> - </InfoTooltip> - </Toggle> - <Toggle - variant="outline" - className="bg-background" - aria-label="Toggle bold" - pressed={sortByName} - onPressedChange={toggleSortByName} - > - <ArrowDownAZ className="mr-2 size-4" /> {t("tags.sort_by_name")} - </Toggle> + <div className="flex flex-col gap-4"> + <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-3"> + <span className="text-2xl">{t("tags.all_tags")}</span> + <div className="flex flex-wrap items-center justify-end gap-2"> + <CreateTagModal /> + <BulkTagAction /> + <Toggle + variant="outline" + className="bg-background" + aria-label={t("tags.drag_and_drop_merging")} + pressed={draggingEnabled} + onPressedChange={toggleDraggingEnabled} + disabled={isBulkEditEnabled} + > + <Combine className="mr-2 size-4" /> + {t("tags.drag_and_drop_merging")} + <InfoTooltip size={15} className="my-auto ml-2" variant="explain"> + <p>{t("tags.drag_and_drop_merging_info")}</p> + </InfoTooltip> + </Toggle> + </div> + </div> + <div className="flex flex-col gap-3"> + <div className="flex w-full items-center gap-2"> + <div className="flex-1"> + <Input + type="search" + value={searchQueryRaw} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder={t("common.search")} + aria-label={t("common.search")} + startIcon={<Search className="h-4 w-4 text-muted-foreground" />} + endIcon={isFetching && <Spinner className="h-4 w-4" />} + autoComplete="off" + className="h-10" + /> + </div> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + className="flex-shrink-0 bg-background" + > + <ArrowDownAZ className="mr-2 size-4" /> + <span className="mr-1 text-sm"> + {t("actions.sort.title")} + </span> + <span className="hidden text-sm font-medium sm:inline"> + {sortLabels[sortBy]} + </span> + <ChevronDown className="ml-2 size-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + <DropdownMenuRadioGroup + value={sortBy} + onValueChange={(value) => setSortBy(value as typeof sortBy)} + > + <DropdownMenuRadioItem value="usage"> + {sortLabels["usage"]} + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="name"> + {sortLabels["name"]} + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="relevance"> + {sortLabels["relevance"]} + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> </div> </div> <Card> <CardHeader> <CardTitle className="flex items-center gap-2"> <span>{t("tags.your_tags")}</span> - <Badge variant="secondary">{humanTags.length}</Badge> + <Badge variant="secondary">{allHumanTags.length}</Badge> </CardTitle> <CardDescription>{t("tags.your_tags_info")}</CardDescription> </CardHeader> - <CardContent>{tagsToPill(humanTags, isBulkEditEnabled)}</CardContent> + <CardContent className="flex flex-col gap-4"> + {tagsToPill( + allHumanTags, + isBulkEditEnabled, + { + emptyMessage: t("tags.no_custom_tags"), + searchEmptyMessage: t("tags.no_tags_match_your_search"), + }, + isHumanTagsLoading, + )} + {hasNextPageHumanTags && ( + <ActionButton + variant="secondary" + onClick={() => fetchNextPageHumanTags()} + loading={isFetchingNextPageHumanTags} + ignoreDemoMode + > + {t("actions.load_more")} + </ActionButton> + )} + </CardContent> </Card> <Card> <CardHeader> <CardTitle className="flex items-center gap-2"> <span>{t("tags.ai_tags")}</span> - <Badge variant="secondary">{aiTags.length}</Badge> + <Badge variant="secondary">{allAiTags.length}</Badge> </CardTitle> <CardDescription>{t("tags.ai_tags_info")}</CardDescription> </CardHeader> - <CardContent>{tagsToPill(aiTags, isBulkEditEnabled)}</CardContent> + <CardContent className="flex flex-col gap-4"> + {tagsToPill( + allAiTags, + isBulkEditEnabled, + { + emptyMessage: t("tags.no_ai_tags"), + searchEmptyMessage: t("tags.no_tags_match_your_search"), + }, + isAiTagsLoading, + )} + {hasNextPageAiTags && ( + <ActionButton + variant="secondary" + onClick={() => fetchNextPageAiTags()} + loading={isFetchingNextPageAiTags} + ignoreDemoMode + > + {t("actions.load_more")} + </ActionButton> + )} + </CardContent> </Card> <Card> <CardHeader> - <CardTitle>{t("tags.unused_tags")}</CardTitle> + <CardTitle className="flex items-center gap-2"> + <span>{t("tags.unused_tags")}</span> + <Badge variant="secondary">{allEmptyTags.length}</Badge> + </CardTitle> <CardDescription>{t("tags.unused_tags_info")}</CardDescription> </CardHeader> - <CardContent> - <Collapsible> - <div className="space-x-1 pb-2"> - <CollapsibleTrigger asChild> - <Button variant="secondary" disabled={emptyTags.length == 0}> - {emptyTags.length > 0 - ? `Show ${emptyTags.length} unused tags` - : "You don't have any unused tags"} - </Button> - </CollapsibleTrigger> - {emptyTags.length > 0 && ( - <DeleteAllUnusedTags numUnusedTags={emptyTags.length} /> - )} - </div> - <CollapsibleContent> - {tagsToPill(emptyTags, isBulkEditEnabled)} - </CollapsibleContent> - </Collapsible> + <CardContent className="flex flex-col gap-4"> + {tagsToPill( + allEmptyTags, + isBulkEditEnabled, + { + emptyMessage: t("tags.no_unused_tags"), + searchEmptyMessage: t("tags.no_unused_tags_match_your_search"), + }, + isEmptyTagsLoading, + )} + {hasNextPageEmptyTags && ( + <ActionButton + variant="secondary" + onClick={() => fetchNextPageEmptyTags()} + loading={isFetchingNextPageEmptyTags} + ignoreDemoMode + > + {t("actions.load_more")} + </ActionButton> + )} + {allEmptyTags.length > 0 && ( + <DeleteAllUnusedTags numUnusedTags={allEmptyTags.length} /> + )} </CardContent> </Card> </div> 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 ( <FormItem className="grow py-4"> <FormControl> - <TagSelector - value={field.value} + <TagAutocomplete + tagId={field.value} onChange={field.onChange} - placeholder="Select a tag to merge into" + className="w-full" /> </FormControl> <FormMessage /> 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 <LoadingSpinner />; } @@ -96,7 +105,7 @@ export function TagAutocomplete({ <CommandList> <CommandEmpty>No tags found.</CommandEmpty> <CommandGroup className="max-h-60 overflow-y-auto"> - {filteredTags.map((tag) => ( + {tags.map((tag) => ( <CommandItem key={tag.id} value={tag.id} @@ -112,11 +121,6 @@ export function TagAutocomplete({ {tag.name} </CommandItem> ))} - {searchQuery && filteredTags.length >= 10 && ( - <div className="px-2 py-2 text-center text-xs text-muted-foreground"> - Showing first 10 results. Keep typing to refine your search. - </div> - )} </CommandGroup> </CommandList> </Command> 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} </Draggable> ); -} +}); 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 <LoadingSpinner />; - } - - return ( - <Select onValueChange={onChange} value={value ?? ""}> - <SelectTrigger className={cn("w-full", className)}> - <SelectValue placeholder={placeholder} /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - {allTags?.tags.map((tag) => { - return ( - <SelectItem key={tag.id} value={tag.id}> - {tag.name} - </SelectItem> - ); - })} - {allTags && allTags.tags.length == 0 && ( - <SelectItem value="notag" disabled> - You don't currently have any tags. - </SelectItem> - )} - </SelectGroup> - </SelectContent> - </Select> - ); -} |
