aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard/bookmarks
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/dashboard/bookmarks
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/dashboard/bookmarks')
-rw-r--r--apps/web/components/dashboard/bookmarks/TagsEditor.tsx483
1 files changed, 337 insertions, 146 deletions
diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
index f80ba963..7c6393c3 100644
--- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
+++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
@@ -1,21 +1,25 @@
-import type { ActionMeta } from "react-select";
-import { useState } from "react";
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
import { useClientConfig } from "@/lib/clientConfig";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
-import { Sparkles } from "lucide-react";
-import CreateableSelect from "react-select/creatable";
-
-import type {
- ZAttachedByEnum,
- ZBookmarkTags,
-} from "@karakeep/shared/types/tags";
-
-interface EditableTag {
- attachedBy: ZAttachedByEnum;
- value?: string;
- label: string;
-}
+import { keepPreviousData } from "@tanstack/react-query";
+import { Command as CommandPrimitive } from "cmdk";
+import { Check, Loader2, Plus, Sparkles, X } from "lucide-react";
+
+import type { ZBookmarkTags } from "@karakeep/shared/types/tags";
export function TagsEditor({
tags: _tags,
@@ -27,167 +31,354 @@ export function TagsEditor({
onDetach: (tag: { tagName: string; tagId: string }) => void;
}) {
const demoMode = !!useClientConfig().demoMode;
-
+ const isDisabled = demoMode;
+ const inputRef = React.useRef<HTMLInputElement>(null);
+ const containerRef = React.useRef<HTMLDivElement>(null);
+ const [open, setOpen] = React.useState(false);
+ const [inputValue, setInputValue] = React.useState("");
const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_tags);
+ const tempIdCounter = React.useRef(0);
+
+ const generateTempId = React.useCallback(() => {
+ tempIdCounter.current += 1;
+ if (
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function"
+ ) {
+ return `temp-${crypto.randomUUID()}`;
+ }
+
+ return `temp-${Date.now()}-${tempIdCounter.current}`;
+ }, []);
- const { data: existingTags, isLoading: isExistingTagsLoading } =
- api.tags.list.useQuery(undefined, {
- select: (data) => ({
- tags: data.tags.sort((a, b) => a.name.localeCompare(b.name)),
- }),
+ React.useEffect(() => {
+ setOptimisticTags((prev) => {
+ let results = prev;
+ for (const tag of _tags) {
+ const idx = results.findIndex((t) => t.name === tag.name);
+ if (idx == -1) {
+ results.push(tag);
+ continue;
+ }
+ if (results[idx].id.startsWith("temp-")) {
+ results[idx] = tag;
+ continue;
+ }
+ }
+ return results;
});
+ }, [_tags]);
+
+ const { data: filteredOptions, isLoading: isExistingTagsLoading } =
+ api.tags.list.useQuery(
+ {
+ nameContains: inputValue,
+ limit: 50,
+ sortBy: inputValue.length > 0 ? "relevance" : "usage",
+ },
+ {
+ select: (data) =>
+ data.tags.map((t) => ({
+ id: t.id,
+ name: t.name,
+ attachedBy:
+ (t.numBookmarksByAttachedType.human ?? 0) > 0
+ ? ("human" as const)
+ : ("ai" as const),
+ })),
+ placeholderData: keepPreviousData,
+ gcTime: inputValue.length > 0 ? 60_000 : 3_600_000,
+ },
+ );
+
+ const selectedValues = optimisticTags.map((tag) => tag.id);
+
+ // Add "create new" option if input doesn't match any existing option
+ const trimmedInputValue = inputValue.trim();
+
+ interface DisplayOption {
+ id: string;
+ name: string;
+ label: string;
+ attachedBy: "human" | "ai";
+ isCreateOption?: boolean;
+ }
+
+ const displayedOptions = React.useMemo<DisplayOption[]>(() => {
+ if (!filteredOptions) return [];
+
+ const baseOptions = filteredOptions.map((option) => ({
+ ...option,
+ label: option.name,
+ }));
+
+ if (!trimmedInputValue) {
+ return baseOptions;
+ }
+
+ const exactMatch = baseOptions.some(
+ (opt) => opt.name.toLowerCase() === trimmedInputValue.toLowerCase(),
+ );
+
+ if (!exactMatch) {
+ return [
+ {
+ id: "create-new",
+ name: trimmedInputValue,
+ label: `Create "${trimmedInputValue}"`,
+ attachedBy: "human" as const,
+ isCreateOption: true,
+ },
+ ...baseOptions,
+ ];
+ }
+
+ return baseOptions;
+ }, [filteredOptions, trimmedInputValue]);
const onChange = (
- _option: readonly EditableTag[],
- actionMeta: ActionMeta<EditableTag>,
+ actionMeta:
+ | { action: "create-option"; name: string }
+ | { action: "select-option"; id: string; name: string }
+ | {
+ action: "remove-value";
+ id: string;
+ name: string;
+ },
) => {
switch (actionMeta.action) {
- case "pop-value":
case "remove-value": {
- if (actionMeta.removedValue.value) {
- setOptimisticTags((prev) =>
- prev.filter((t) => t.id != actionMeta.removedValue.value),
- );
- onDetach({
- tagId: actionMeta.removedValue.value,
- tagName: actionMeta.removedValue.label,
- });
- }
+ setOptimisticTags((prev) => prev.filter((t) => t.id != actionMeta.id));
+ onDetach({
+ tagId: actionMeta.id,
+ tagName: actionMeta.name,
+ });
break;
}
case "create-option": {
+ const tempId = generateTempId();
setOptimisticTags((prev) => [
...prev,
{
- id: "",
- name: actionMeta.option.label,
+ id: tempId,
+ name: actionMeta.name,
attachedBy: "human" as const,
},
]);
- onAttach({ tagName: actionMeta.option.label });
+ onAttach({ tagName: actionMeta.name });
break;
}
case "select-option": {
- if (actionMeta.option) {
- setOptimisticTags((prev) => [
+ setOptimisticTags((prev) => {
+ if (prev.some((tag) => tag.id === actionMeta.id)) {
+ return prev;
+ }
+
+ return [
...prev,
{
- id: actionMeta.option?.value ?? "",
- name: actionMeta.option!.label,
+ id: actionMeta.id,
+ name: actionMeta.name,
attachedBy: "human" as const,
},
- ]);
- onAttach({
- tagName: actionMeta.option.label,
- tagId: actionMeta.option?.value,
- });
- }
+ ];
+ });
+ onAttach({
+ tagName: actionMeta.name,
+ tagId: actionMeta.id,
+ });
break;
}
}
};
+ const createTag = () => {
+ if (!inputValue.trim()) return;
+ onChange({ action: "create-option", name: inputValue.trim() });
+ setInputValue("");
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Escape") {
+ setOpen(false);
+ } else if (
+ e.key === "Backspace" &&
+ !inputValue &&
+ optimisticTags.length > 0
+ ) {
+ const lastTag = optimisticTags.slice(-1)[0];
+ onChange({
+ action: "remove-value",
+ id: lastTag.id,
+ name: lastTag.name,
+ });
+ }
+ };
+
+ const handleSelect = (option: DisplayOption) => {
+ if (option.isCreateOption) {
+ onChange({ action: "create-option", name: option.name });
+ setInputValue("");
+ inputRef.current?.focus();
+ return;
+ }
+
+ // If already selected, remove it
+ if (selectedValues.includes(option.id)) {
+ onChange({
+ action: "remove-value",
+ id: option.id,
+ name: option.name,
+ });
+ } else {
+ // Add the new tag
+ onChange({
+ action: "select-option",
+ id: option.id,
+ name: option.name,
+ });
+ }
+
+ // Reset input and keep focus
+ setInputValue("");
+ inputRef.current?.focus();
+ };
+
+ const handleOpenChange = (open: boolean) => {
+ setOpen(open);
+ if (open) {
+ // Focus the input
+ setTimeout(() => {
+ inputRef.current?.focus();
+ }, 0);
+ }
+ };
+
return (
- <CreateableSelect
- isDisabled={demoMode}
- onChange={onChange}
- options={
- existingTags?.tags.map((t) => ({
- label: t.name,
- value: t.id,
- attachedBy: "human" as const,
- })) ?? []
- }
- value={optimisticTags.slice().map((t) => ({
- label: t.name,
- value: t.id,
- attachedBy: t.attachedBy,
- }))}
- isMulti
- closeMenuOnSelect={false}
- isClearable={false}
- isLoading={isExistingTagsLoading}
- theme={(theme) => ({
- ...theme,
- // This color scheme doesn't support disabled options.
- colors: {
- ...theme.colors,
- primary: "hsl(var(--accent))",
- primary50: "hsl(var(--accent))",
- primary75: "hsl(var(--accent))",
- primary25: "hsl(var(--accent))",
- },
- })}
- styles={{
- multiValueRemove: () => ({
- backgroundColor: "transparent",
- }),
- valueContainer: (styles) => ({
- ...styles,
- padding: "0.5rem",
- maxHeight: "14rem",
- overflowY: "auto",
- scrollbarWidth: "thin",
- }),
- container: (styles) => ({
- ...styles,
- width: "100%",
- }),
- control: (styles) => ({
- ...styles,
- overflow: "hidden",
- backgroundColor: "hsl(var(--background))",
- borderColor: "hsl(var(--border))",
- ":hover": {
- borderColor: "hsl(var(--border))",
- },
- }),
- input: (styles) => ({
- ...styles,
- color: "rgb(156 163 175)",
- }),
- menu: (styles) => ({
- ...styles,
- overflow: "hidden",
- color: "rgb(156 163 175)",
- }),
- placeholder: (styles) => ({
- ...styles,
- color: "hsl(var(--muted-foreground))",
- }),
- }}
- components={{
- MultiValueContainer: ({ children, data }) => (
- <div
- className={cn(
- "flex min-h-8 space-x-1 rounded px-2",
- (data as { attachedBy: string }).attachedBy == "ai"
- ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white"
- : "bg-accent",
- )}
+ <div ref={containerRef}>
+ <Popover open={open && !isDisabled} onOpenChange={handleOpenChange}>
+ <Command shouldFilter={false}>
+ <PopoverTrigger asChild>
+ <div
+ className={cn(
+ "relative flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
+ isDisabled && "cursor-not-allowed opacity-50",
+ )}
+ >
+ {optimisticTags.length > 0 && (
+ <>
+ {optimisticTags.map((tag) => (
+ <div
+ key={tag.id}
+ className={cn(
+ "flex min-h-8 space-x-1 rounded px-2",
+ tag.attachedBy == "ai"
+ ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white"
+ : "bg-accent",
+ )}
+ >
+ <div className="m-auto flex gap-2">
+ {tag.attachedBy === "ai" && (
+ <Sparkles className="m-auto size-4" />
+ )}
+ {tag.name}
+ {!isDisabled && (
+ <button
+ type="button"
+ className="rounded-full outline-none ring-offset-background focus:ring-1 focus:ring-ring focus:ring-offset-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ onChange({
+ action: "remove-value",
+ id: tag.id,
+ name: tag.name,
+ });
+ }}
+ >
+ <X className="h-3 w-3" />
+ <span className="sr-only">Remove {tag.name}</span>
+ </button>
+ )}
+ </div>
+ </div>
+ ))}
+ </>
+ )}
+ <CommandPrimitive.Input
+ ref={inputRef}
+ value={inputValue}
+ onKeyDown={handleKeyDown}
+ onValueChange={(v) => setInputValue(v)}
+ className="bg-transparent outline-none placeholder:text-muted-foreground"
+ style={{ width: `${Math.max(inputValue.length, 1)}ch` }}
+ disabled={isDisabled}
+ />
+ {isExistingTagsLoading && (
+ <div className="absolute bottom-2 right-2">
+ <Loader2 className="h-4 w-4 animate-spin opacity-50" />
+ </div>
+ )}
+ </div>
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-[--radix-popover-trigger-width] p-0"
+ align="start"
>
- {children}
- </div>
- ),
- MultiValueLabel: ({ children, data }) => (
- <div className="m-auto flex gap-2">
- {(data as { attachedBy: string }).attachedBy == "ai" && (
- <Sparkles className="m-auto size-4" />
- )}
- {children}
- </div>
- ),
- DropdownIndicator: () => <span />,
- IndicatorSeparator: () => <span />,
- }}
- classNames={{
- multiValueRemove: () => "my-auto",
- valueContainer: () => "gap-2 bg-background text-sm",
- menu: () => "dark:text-gray-300",
- menuList: () => "bg-background text-sm",
- option: () => "text-red-500",
- input: () => "dark:text-gray-300",
- }}
- />
+ <CommandList className="max-h-64">
+ {displayedOptions.length === 0 ? (
+ <CommandEmpty>
+ {trimmedInputValue ? (
+ <div className="flex items-center justify-between px-2 py-1.5">
+ <span>Create &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>
);
}