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:
+175
-27
@@ -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
@@ -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
@@ -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
@@ -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: [],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user