- Rename a tag to clean it up, or rename it to an existing tag's name to merge them. Delete
- removes a tag from every image. These changes apply across your whole library.
-
- {entries.map((entry) => (
-
- ))}
+
+
+
+
+
+ {entries.length.toLocaleString()} tags
+ {totalUses.toLocaleString()} uses
+ {query.trim() ? (
+
+ {filteredEntries.length.toLocaleString()} matches
+
+ ) : null}
+
+
+ Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.
+
+
+
+
+
+
setQuery(event.target.value)}
+ placeholder="Filter tags"
+ />
+ {query ? (
+
+
+
+
+
+ ) : 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: [],
});