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.
This commit is contained in:
2026-06-29 16:29:12 +01:00
parent 9144be2518
commit af3c8418ee
4 changed files with 312 additions and 49 deletions
+175 -27
View File
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ThemedDropdown } from "./ThemedDropdown";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { mediaSrc } from "../lib/mediaSrc"; 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 type TagManageSort = "count_desc" | "count_asc" | "az" | "za";
// the new name already exists) and delete across the whole library.
function TagManageRow({ 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, entry,
onSearch, onSearch,
onRename, onRename,
@@ -708,12 +719,17 @@ function TagManageRow({
}; };
return ( return (
<div className="group flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-white/[0.04]"> <div
<div className="min-w-0 flex-1"> data-tag-manager-tile
className={`tag-manager-tile group relative min-w-0 rounded-lg border border-white/[0.06] bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:border-white/[0.14] focus-within:bg-white/[0.045] hover:border-white/[0.12] hover:bg-white/[0.04] ${
editing || confirming ? "min-h-[82px]" : "min-h-[46px]"
}`}
>
<div className="flex min-w-0 items-start gap-2">
{editing ? ( {editing ? (
<input <input
ref={inputRef} ref={inputRef}
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/40" className="tag-manager-edit-input min-w-0 flex-1 rounded-md border border-blue-400/35 bg-black/25 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/30"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -723,29 +739,31 @@ function TagManageRow({
disabled={busy} disabled={busy}
/> />
) : ( ) : (
<Tooltip label="Search this tag" delay={500} anchorToCursor className="min-w-0 flex-1">
<button <button
className="truncate text-left text-sm text-white/85 transition-colors hover:text-white" className="tag-manager-name block w-full truncate pr-36 text-left text-sm font-medium text-white/85 transition-colors hover:text-white"
onClick={() => onSearch(entry.tag)} onClick={() => onSearch(entry.tag)}
title="Search this tag"
> >
{entry.tag} {entry.tag}
</button> </button>
</Tooltip>
)} )}
<span className="tag-manager-count absolute right-2.5 top-2 shrink-0 rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] tabular-nums text-white/38">
{entry.count.toLocaleString()}
</span>
</div> </div>
<span className="shrink-0 text-xs tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
{editing ? ( {editing ? (
<div className="flex shrink-0 items-center gap-1"> <div className="mt-2 flex items-center gap-1">
<button <button
className="rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50" className="tag-manager-save rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
onClick={() => void commitRename()} onClick={() => void commitRename()}
disabled={busy || !value.trim()} disabled={busy || !value.trim()}
title="Rename (merges into the target if it already exists)"
> >
Save Save
</button> </button>
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80" className="tag-manager-secondary rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setEditing(false)} onClick={() => setEditing(false)}
disabled={busy} disabled={busy}
> >
@@ -753,16 +771,16 @@ function TagManageRow({
</button> </button>
</div> </div>
) : confirming ? ( ) : confirming ? (
<div className="flex shrink-0 items-center gap-1"> <div className="mt-2 flex items-center gap-1">
<button <button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50" className="tag-manager-danger rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50"
onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }} onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }}
disabled={busy} disabled={busy}
> >
Delete Delete
</button> </button>
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80" className="tag-manager-secondary rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setConfirming(false)} onClick={() => setConfirming(false)}
disabled={busy} disabled={busy}
> >
@@ -770,16 +788,17 @@ function TagManageRow({
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"> <div className="pointer-events-none absolute right-12 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
<Tooltip label="Rename or merge into another tag" delay={400} anchorToCursor>
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80" className="tag-manager-action rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
title="Rename or merge into another tag"
> >
Rename Rename
</button> </button>
</Tooltip>
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80" className="tag-manager-action tag-manager-action-danger rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
> >
Delete Delete
@@ -801,15 +820,136 @@ function TagManageList({
onRename: (from: string, to: string) => Promise<void>; onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>; onDelete: (tag: string) => Promise<void>;
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [sort, setSort] = useState<TagManageSort>("count_desc");
const [columns, setColumns] = useState(3);
useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const update = () => {
const width = el.getBoundingClientRect().width;
setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const filteredEntries = useMemo(() => {
const needle = query.trim().toLowerCase();
const filtered = needle
? entries.filter((entry) => entry.tag.toLowerCase().includes(needle))
: entries;
return [...filtered].sort((left, right) => {
switch (sort) {
case "count_asc":
return left.count - right.count || left.tag.localeCompare(right.tag);
case "az":
return left.tag.localeCompare(right.tag);
case "za":
return right.tag.localeCompare(left.tag);
case "count_desc":
default:
return right.count - left.count || left.tag.localeCompare(right.tag);
}
});
}, [entries, query, sort]);
const rowCount = Math.ceil(filteredEntries.length / columns);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => 54,
overscan: 7,
});
const visibleItems = rowVirtualizer.getVirtualItems();
const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]);
return ( return (
<div className="mx-auto w-full max-w-2xl overflow-y-auto px-6 py-6"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<p className="mb-3 px-3 text-[11px] leading-relaxed text-white/30"> <div className="tag-manager-header shrink-0 border-b border-white/[0.05] bg-black/[0.08] px-6 py-4">
Rename a tag to clean it up, or rename it to an existing tag's name to merge them. Delete <div className="flex flex-wrap items-end justify-between gap-3">
removes a tag from every image. These changes apply across your whole library. <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/32">
<span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">{entries.length.toLocaleString()} tags</span>
<span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">{totalUses.toLocaleString()} uses</span>
{query.trim() ? (
<span className="tag-manager-match rounded-full bg-blue-500/10 px-2 py-1 text-blue-200/70 tabular-nums">
{filteredEntries.length.toLocaleString()} matches
</span>
) : null}
</div>
<p className="tag-manager-help mt-2 max-w-2xl text-[11px] leading-relaxed text-white/28">
Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.
</p> </p>
<div className="divide-y divide-white/[0.05]"> </div>
{entries.map((entry) => (
<TagManageRow <div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
<div className="relative min-w-[220px] flex-1 sm:max-w-sm">
<input
className="tag-manager-filter h-9 w-full rounded-lg border border-white/8 bg-black/20 px-3 pr-8 text-sm text-white/85 outline-none transition-colors placeholder:text-white/22 focus:border-blue-400/35 focus:bg-black/28"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter tags"
/>
{query ? (
<span className="absolute right-2 top-2 z-10">
<Tooltip label="Clear filter" delay={400} anchorToCursor>
<button
className="tag-manager-clear inline-flex h-5 w-5 items-center justify-center rounded-md text-white/35 transition-colors hover:bg-white/8 hover:text-white/75 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/70"
onClick={() => setQuery("")}
aria-label="Clear tag filter"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</Tooltip>
</span>
) : null}
</div>
<ThemedDropdown
value={sort}
onChange={(value) => setSort(value as TagManageSort)}
options={TAG_MANAGE_SORTS}
ariaLabel="Sort managed tags"
align="right"
/>
</div>
</div>
</div>
<div ref={scrollRef} data-tag-manager-scroll className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div ref={measureRef} className="mx-auto w-full max-w-7xl">
{filteredEntries.length === 0 ? (
<div className="tag-manager-empty flex h-48 items-center justify-center rounded-lg border border-white/[0.06] bg-white/[0.02] text-sm text-white/30">
No tags match that filter.
</div>
) : (
<div
className="relative w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{visibleItems.map((virtualRow) => {
const start = virtualRow.index * columns;
const rowEntries = filteredEntries.slice(start, start + columns);
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute left-0 top-0 grid w-full gap-x-2 pb-2"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowEntries.map((entry) => (
<TagManageTile
key={entry.tag} key={entry.tag}
entry={entry} entry={entry}
onSearch={onSearch} onSearch={onSearch}
@@ -818,6 +958,12 @@ function TagManageList({
/> />
))} ))}
</div> </div>
);
})}
</div>
)}
</div>
</div>
</div> </div>
); );
} }
@@ -864,6 +1010,8 @@ export function TagCloud() {
: hasEntries : hasEntries
? exploreMode === "visual" ? exploreMode === "visual"
? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
: manageTags
? `${entryCount} tag${entryCount !== 1 ? "s" : ""} available to manage`
: visibleTagCount < entryCount : visibleTagCount < entryCount
? `${visibleTagCount} of ${entryCount} tags shown — click any to search` ? `${visibleTagCount} of ${entryCount} tags shown — click any to search`
: `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search`
+14 -2
View File
@@ -1,5 +1,5 @@
import { emit } from "@tauri-apps/api/event"; 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 { compareImages, createMockDb, mockExif } from "./mockFixtures";
import { getMockScenario } from "./mockScenarios"; import { getMockScenario } from "./mockScenarios";
@@ -58,12 +58,22 @@ function searchTags(payload: unknown) {
.filter(([, tags]) => tags.some((tag) => tag.tag.toLowerCase().includes(query))) .filter(([, tags]) => tags.some((tag) => tag.tag.toLowerCase().includes(query)))
.map(([imageId]) => Number(imageId)), .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)) .filter((image) => matchingIds.has(image.id))
.sort(compareImages((p.sort ?? "date_desc") as SortOrder)); .sort(compareImages((p.sort ?? "date_desc") as SortOrder));
return page(images, Number(p.offset ?? 0), Number(p.limit ?? 200)); 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[] { function semanticSearch(payload: unknown): ImageRecord[] {
const p = params(payload); const p = params(payload);
const query = String(p.query ?? "").toLowerCase(); const query = String(p.query ?? "").toLowerCase();
@@ -233,6 +243,8 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
return semanticSearch(payload); return semanticSearch(payload);
case "search_images_by_tag": case "search_images_by_tag":
return searchTags(payload); return searchTags(payload);
case "search_tags_autocomplete":
return searchTagsAutocomplete(payload);
case "get_images_by_ids": case "get_images_by_ids":
return (p.image_ids ?? []).map((id: number) => db.images.find((image) => image.id === id)).filter(Boolean); return (p.image_ids ?? []).map((id: number) => db.images.find((image) => image.id === id)).filter(Boolean);
case "find_similar_images": case "find_similar_images":
+101
View File
@@ -218,6 +218,107 @@ html[data-theme="subtle-light"] .explore-spinner {
border-top-color: rgb(17 24 39 / 0.55) !important; 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 { html[data-theme="subtle-light"] .feature-scope-trigger {
background: #f8f6ef !important; background: #f8f6ef !important;
border-color: #d0c8ba !important; border-color: #d0c8ba !important;
+4 -2
View File
@@ -2370,9 +2370,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
renameTag: async (from, to) => { renameTag: async (from, to) => {
await invoke("rename_tag", { params: { from, to } }); await invoke("rename_tag", { params: { from, to } });
// Tag content changed — invalidate the explore-tags and tag-cloud caches. // 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({ set({
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
exploreTagEntries: [],
tagCloudFolderId: undefined, tagCloudFolderId: undefined,
tagCloudEntries: [], tagCloudEntries: [],
}); });
@@ -2388,9 +2389,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
deleteTag: async (tag) => { deleteTag: async (tag) => {
const removed = await invoke<number>("delete_tag", { params: { tag } }); const removed = await invoke<number>("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({ set({
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
exploreTagEntries: [],
tagCloudFolderId: undefined, tagCloudFolderId: undefined,
tagCloudEntries: [], tagCloudEntries: [],
}); });