aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-09-28 11:03:48 +0100
committerGitHub <noreply@github.com>2025-09-28 11:03:48 +0100
commit62f7d900c52784ff05d933b52379e5455ea6bd00 (patch)
tree2702d74c96576447974af84850f3ba6b66beeeb4 /apps/web/components
parent9fe09bfa9021c8d85d2d9aef591936101cab19f6 (diff)
downloadkarakeep-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')
-rw-r--r--apps/web/components/dashboard/bookmarks/TagsEditor.tsx483
-rw-r--r--apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx11
-rw-r--r--apps/web/components/dashboard/tags/AllTagsView.tsx434
-rw-r--r--apps/web/components/dashboard/tags/MergeTagModal.tsx8
-rw-r--r--apps/web/components/dashboard/tags/MultiTagSelector.tsx4
-rw-r--r--apps/web/components/dashboard/tags/TagAutocomplete.tsx46
-rw-r--r--apps/web/components/dashboard/tags/TagPill.tsx4
-rw-r--r--apps/web/components/dashboard/tags/TagSelector.tsx57
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 &quot;{trimmedInputValue}&quot;</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&apos;t currently have any tags.
- </SelectItem>
- )}
- </SelectGroup>
- </SelectContent>
- </Select>
- );
-}