From af3c8418eeecca5c0d2b1b5a48dc02b89dbe1133 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 16:29:12 +0100 Subject: [PATCH] feat: virtualized tag manager with filter, sort, and light-theme support Replaces the flat TagManageRow list with a virtualized grid of TagManageTile cards using @tanstack/react-virtual and dynamic measured heights (46px idle, 82px when editing/confirming). Adds a filter input and a sort dropdown (most-used / least-used / A-Z / Z-A). renameTag and deleteTag no longer clear exploreTagEntries on invalidation so the manager keeps its filter/sort state during the background refresh. Light-theme overrides cover all new tag-manager class names. --- src/components/TagCloud.tsx | 238 +++++++++++++++++++++++++++++------- src/dev/mockBackend.ts | 16 ++- src/index.css | 101 +++++++++++++++ src/store.ts | 6 +- 4 files changed, 312 insertions(+), 49 deletions(-) diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index 9b9002a..b2077ba 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; +import { ThemedDropdown } from "./ThemedDropdown"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; @@ -666,9 +668,18 @@ function ClusterCloud({ ); } -// A flat, manageable row for a single tag — rename (which doubles as merge when -// the new name already exists) and delete across the whole library. -function TagManageRow({ +type TagManageSort = "count_desc" | "count_asc" | "az" | "za"; + +const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [ + { value: "count_desc", label: "Most used" }, + { value: "count_asc", label: "Least used" }, + { value: "az", label: "A-Z" }, + { value: "za", label: "Z-A" }, +]; + +// Compact management tile for a single tag. Rename doubles as merge when the new +// name already exists, and delete applies across the scoped tag set. +function TagManageTile({ entry, onSearch, onRename, @@ -708,12 +719,17 @@ function TagManageRow({ }; return ( -
-
+
+
{editing ? ( setValue(e.target.value)} onKeyDown={(e) => { @@ -723,29 +739,31 @@ function TagManageRow({ disabled={busy} /> ) : ( - + + + )} + + {entry.count.toLocaleString()} +
- {entry.count.toLocaleString()} {editing ? ( -
+
) : confirming ? ( -
+
) : ( -
+
+ + + - + + + ) : null} +
+ setSort(value as TagManageSort)} + options={TAG_MANAGE_SORTS} + ariaLabel="Sort managed tags" + align="right" + /> +
+
+
+ +
+
+ {filteredEntries.length === 0 ? ( +
+ No tags match that filter. +
+ ) : ( +
+ {visibleItems.map((virtualRow) => { + const start = virtualRow.index * columns; + const rowEntries = filteredEntries.slice(start, start + columns); + return ( +
+ {rowEntries.map((entry) => ( + + ))} +
+ ); + })} +
+ )} +
); @@ -864,6 +1010,8 @@ export function TagCloud() { : hasEntries ? exploreMode === "visual" ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` + : manageTags + ? `${entryCount} tag${entryCount !== 1 ? "s" : ""} available to manage` : visibleTagCount < entryCount ? `${visibleTagCount} of ${entryCount} tags shown — click any to search` : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 7bd8fd1..ef0c7eb 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -1,5 +1,5 @@ import { emit } from "@tauri-apps/api/event"; -import type { Album, Folder, ImageRecord, ImageTag, SortOrder } from "../store"; +import type { Album, ExploreTagEntry, Folder, ImageRecord, ImageTag, SortOrder } from "../store"; import { compareImages, createMockDb, mockExif } from "./mockFixtures"; import { getMockScenario } from "./mockScenarios"; @@ -58,12 +58,22 @@ function searchTags(payload: unknown) { .filter(([, tags]) => tags.some((tag) => tag.tag.toLowerCase().includes(query))) .map(([imageId]) => Number(imageId)), ); - const images = filterImages(db.images, payload) + const images = filterImages(db.images, { params: { ...p, query: "", search: "" } }) .filter((image) => matchingIds.has(image.id)) .sort(compareImages((p.sort ?? "date_desc") as SortOrder)); return page(images, Number(p.offset ?? 0), Number(p.limit ?? 200)); } +function searchTagsAutocomplete(payload: unknown): ExploreTagEntry[] { + const p = params(payload); + const query = String(p.query ?? "").trim().toLowerCase(); + const limit = Number(p.limit ?? 10); + return db.exploreTags + .filter((entry) => !query || entry.tag.toLowerCase().includes(query)) + .sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)) + .slice(0, limit); +} + function semanticSearch(payload: unknown): ImageRecord[] { const p = params(payload); const query = String(p.query ?? "").toLowerCase(); @@ -233,6 +243,8 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise return semanticSearch(payload); case "search_images_by_tag": return searchTags(payload); + case "search_tags_autocomplete": + return searchTagsAutocomplete(payload); case "get_images_by_ids": return (p.image_ids ?? []).map((id: number) => db.images.find((image) => image.id === id)).filter(Boolean); case "find_similar_images": diff --git a/src/index.css b/src/index.css index e573b50..d79b1dd 100644 --- a/src/index.css +++ b/src/index.css @@ -218,6 +218,107 @@ html[data-theme="subtle-light"] .explore-spinner { border-top-color: rgb(17 24 39 / 0.55) !important; } +html[data-theme="subtle-light"] .tag-manager-header { + background: rgb(244 242 234 / 0.72) !important; + border-color: #d8d2c7 !important; +} + +html[data-theme="subtle-light"] .tag-manager-stat { + background: rgb(31 41 55 / 0.06) !important; + color: #5f5a52 !important; +} + +html[data-theme="subtle-light"] .tag-manager-match { + background: rgb(37 99 235 / 0.12) !important; + color: #1d4ed8 !important; +} + +html[data-theme="subtle-light"] .tag-manager-help, +html[data-theme="subtle-light"] .tag-manager-empty { + color: #6f6a61 !important; +} + +html[data-theme="subtle-light"] .tag-manager-filter { + background: #fbfaf6 !important; + border-color: #cfc7b8 !important; + color: #111827 !important; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.64) !important; +} + +html[data-theme="subtle-light"] .tag-manager-filter::placeholder { + color: #8a8378 !important; +} + +html[data-theme="subtle-light"] .tag-manager-filter:focus { + background: #fffdfa !important; + border-color: #7aa2e3 !important; +} + +html[data-theme="subtle-light"] .tag-manager-clear { + color: #6b645a !important; +} + +html[data-theme="subtle-light"] .tag-manager-clear:hover { + background: rgb(31 41 55 / 0.08) !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .tag-manager-tile { + background: rgb(251 250 246 / 0.5) !important; + border-color: rgb(151 141 126 / 0.22) !important; +} + +html[data-theme="subtle-light"] .tag-manager-tile:hover, +html[data-theme="subtle-light"] .tag-manager-tile:focus-within { + background: rgb(255 253 250 / 0.74) !important; + border-color: rgb(151 141 126 / 0.36) !important; +} + +html[data-theme="subtle-light"] .tag-manager-name { + color: #1f2937 !important; +} + +html[data-theme="subtle-light"] .tag-manager-name:hover { + color: #111827 !important; +} + +html[data-theme="subtle-light"] .tag-manager-count { + background: rgb(31 41 55 / 0.07) !important; + color: #6b645a !important; +} + +html[data-theme="subtle-light"] .tag-manager-edit-input { + background: #fffdfa !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .tag-manager-secondary, +html[data-theme="subtle-light"] .tag-manager-action { + background: rgb(255 253 250 / 0.92) !important; + border-color: rgb(31 41 55 / 0.12) !important; + color: #5f5a52 !important; +} + +html[data-theme="subtle-light"] .tag-manager-secondary:hover, +html[data-theme="subtle-light"] .tag-manager-action:hover { + background: rgb(31 41 55 / 0.06) !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .tag-manager-save { + color: #1d4ed8 !important; +} + +html[data-theme="subtle-light"] .tag-manager-danger, +html[data-theme="subtle-light"] .tag-manager-action-danger:hover { + color: #b91c1c !important; +} + +html[data-theme="subtle-light"] .tag-manager-empty { + background: rgb(251 250 246 / 0.42) !important; + border-color: #d8d2c7 !important; +} + html[data-theme="subtle-light"] .feature-scope-trigger { background: #f8f6ef !important; border-color: #d0c8ba !important; diff --git a/src/store.ts b/src/store.ts index 72ab5a7..aec0a3a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2370,9 +2370,10 @@ export const useGalleryStore = create((set, get) => ({ renameTag: async (from, to) => { await invoke("rename_tag", { params: { from, to } }); // Tag content changed — invalidate the explore-tags and tag-cloud caches. + // Keep the current tag list visible while the refresh runs so manager UI + // state such as filtering and sorting is not lost to a loading remount. set({ exploreTagsFolderId: undefined, - exploreTagEntries: [], tagCloudFolderId: undefined, tagCloudEntries: [], }); @@ -2388,9 +2389,10 @@ export const useGalleryStore = create((set, get) => ({ deleteTag: async (tag) => { const removed = await invoke("delete_tag", { params: { tag } }); + // Keep the current tag list visible while the refresh runs so manager UI + // state such as filtering and sorting is not lost to a loading remount. set({ exploreTagsFolderId: undefined, - exploreTagEntries: [], tagCloudFolderId: undefined, tagCloudEntries: [], });