feat(discovery): surface all tags and guard folder removal

Include tags used by a single image in Explore and invalidate the tag cache after edits so changes appear immediately. Add an accessible, time-limited confirmation step before removing indexed folders from the sidebar.
This commit is contained in:
2026-06-06 19:52:05 +01:00
parent 6824dcffb3
commit 2cdab000fb
3 changed files with 71 additions and 22 deletions
+1 -1
View File
@@ -1523,7 +1523,7 @@ pub fn get_explore_tags(
JOIN images i ON i.id = t.image_id JOIN images i ON i.id = t.image_id
WHERE (?1 IS NULL OR i.folder_id = ?1) WHERE (?1 IS NULL OR i.folder_id = ?1)
GROUP BY t.tag GROUP BY t.tag
HAVING COUNT(DISTINCT t.image_id) >= 2 HAVING COUNT(DISTINCT t.image_id) >= 1
ORDER BY tag_count DESC, t.tag ASC ORDER BY tag_count DESC, t.tag ASC
LIMIT ?2", LIMIT ?2",
)?; )?;
+64 -20
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store"; import { useGalleryStore, Folder, IndexProgress } from "../store";
@@ -12,6 +13,22 @@ function FolderItem({
}) { }) {
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore(); const { selectFolder, removeFolder, reindexFolder } = useGalleryStore();
const isIndexing = progress && !progress.done; const isIndexing = progress && !progress.done;
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
useEffect(() => {
if (!confirmingRemoval) return;
const timeout = window.setTimeout(() => {
setConfirmingRemoval(false);
}, 4000);
return () => window.clearTimeout(timeout);
}, [confirmingRemoval]);
const handleRemove = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
await removeFolder(folder.id);
};
return ( return (
<div <div
@@ -46,27 +63,54 @@ function FolderItem({
)} )}
</div> </div>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> {confirmingRemoval ? (
<button <div
className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200" className="flex items-center gap-1 shrink-0"
title="Re-index" role="group"
onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }} aria-label={`Confirm removal of ${folder.name}`}
onClick={(event) => event.stopPropagation()}
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <button
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} className="px-1.5 py-1 rounded-md text-[10px] font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 hover:text-red-300"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> onClick={handleRemove}
</svg> >
</button> Remove
<button </button>
className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400" <button
title="Remove folder" className="px-1.5 py-1 rounded-md text-[10px] font-medium text-gray-400 hover:text-gray-100 hover:bg-white/10"
onClick={(e) => { e.stopPropagation(); removeFolder(folder.id); }} onClick={() => setConfirmingRemoval(false)}
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> Cancel
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> </button>
</svg> </div>
</button> ) : (
</div> <div className="flex gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity shrink-0">
<button
className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200"
title="Re-index"
aria-label={`Re-index ${folder.name}`}
onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400"
title="Remove folder"
aria-label={`Remove ${folder.name}`}
onClick={(event) => {
event.stopPropagation();
setConfirmingRemoval(true);
}}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
</div> </div>
); );
} }
+6 -1
View File
@@ -1360,15 +1360,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
addUserTag: async (imageId, tag) => { addUserTag: async (imageId, tag) => {
return invoke<ImageTag>("add_user_tag", { const result = await invoke<ImageTag>("add_user_tag", {
params: { image_id: imageId, tag }, params: { image_id: imageId, tag },
}); });
// Invalidate explore tags cache so new tag appears immediately
set({ exploreTagsFolderId: undefined });
return result;
}, },
removeTag: async (tagId) => { removeTag: async (tagId) => {
await invoke<void>("remove_tag", { await invoke<void>("remove_tag", {
params: { tag_id: tagId }, params: { tag_id: tagId },
}); });
// Invalidate explore tags cache so removed tag disappears immediately
set({ exploreTagsFolderId: undefined });
}, },
loadDuplicateScanCache: async (folderId = null) => { loadDuplicateScanCache: async (folderId = null) => {