Compare commits

...

4 Commits

Author SHA1 Message Date
LyAhn 68a9df5ab3 feat(lightbox): two-column metadata layout
github/actions/ci GitHub Actions CI finished: failure
Lay the lightbox info-panel metadata out in a two-column grid: paired fields
(Dimensions/Duration, Video codec/Audio codec, Type/File size) sit side by side
while Rating, Modified, and Embedding span the full width. More compact panel
with less scrolling. Tags and EXIF sections are unchanged.
2026-06-29 20:35:13 +01:00
LyAhn 79ce458fd5 feat(tags): open the tag manager from Settings
Add an "Open tag manager" button under a new Tag library group in Settings →
AI Workspace. It closes Settings and jumps to Explore's tag Manage mode.

To make manage mode reachable from outside the Explore view, lift its flag out
of TagCloud's local state into the store (tagManagerOpen / setTagManagerOpen)
behind an openTagManager() action. Manage mode is reset whenever Explore is
entered normally or the visual-cluster view is selected, so openTagManager()
stays the only path that opens it programmatically.
2026-06-29 20:26:49 +01:00
LyAhn a9a8f8422e feat(ui): quick theme switch from the settings cog
Right-clicking the settings cog in the title bar opens a small theme menu
(Phokus / Subtle Light / Conventional Dark) with the active theme checked,
anchored under the cog and dismissed on outside-click or Escape. Left-click
still opens Settings. Keeps theme switching one gesture away without cluttering
the title bar with another icon.
2026-06-29 20:26:08 +01:00
LyAhn ab7022e118 perf(explore): instant tag-cloud cache hits + fix stale-on-switch loading
The get_tag_cloud cache key was built by loading and hashing every embedding
blob for the scope *before* checking the cache, so even a cache hit re-read
hundreds of MB on large libraries and stalled Explore for several seconds.
Validate the cache from a lightweight image-ID-set signature plus the embedding
revision instead, so a hit never loads embeddings. The ID-set hash keeps the key
membership-sensitive (add/remove/move between folders) and the revision covers
an image being re-embedded in place. Cache write failures are now logged rather
than silently ignored.

On the frontend, switching folders (or re-entering Explore) no longer leaves the
previous folder's clusters/tags on screen with no loading indicator:
loadTagCloud/loadExploreTags clear stale entries on a real folder switch. The
displayed folder is tracked separately (exploreTagsShownFolderId) from the
cache-dirty marker so a same-folder invalidation (tag edits, new AI tags) does
not masquerade as a switch and wipe the visible list mid-refresh.
2026-06-29 20:25:38 +01:00
8 changed files with 244 additions and 45 deletions
+16 -1
View File
@@ -9,6 +9,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Added ### Added
- **Quick theme switch** — right-click the settings cog in the title bar to
switch theme (Phokus / Subtle Light / Conventional Dark) on the spot, without
opening Settings.
- **Albums** — curate your own collections. A new Albums section in the sidebar - **Albums** — curate your own collections. A new Albums section in the sidebar
(with cover thumbnails, kept visually distinct from Libraries) lets you create, (with cover thumbnails, kept visually distinct from Libraries) lets you create,
rename, reorder (drag the row), and open albums; albums can span multiple rename, reorder (drag the row), and open albums; albums can span multiple
@@ -31,7 +34,8 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
- **Tag management** — Explore → Tag Cloud gains a Manage mode with a flat tag - **Tag management** — Explore → Tag Cloud gains a Manage mode with a flat tag
list where you can rename a tag, merge it into another (rename it to an list where you can rename a tag, merge it into another (rename it to an
existing tag's name), or delete it from every image. Changes apply across the existing tag's name), or delete it from every image. Changes apply across the
whole library. whole library. Settings → AI Workspace also has an "Open tag manager" button
that jumps straight into it.
- **Camera info in the lightbox** — the image info panel now shows EXIF details - **Camera info in the lightbox** — the image info panel now shows EXIF details
(camera, lens, aperture, shutter, ISO, focal length) and, when a photo is (camera, lens, aperture, shutter, ISO, focal length) and, when a photo is
geotagged, its GPS coordinates as a link that opens the location in your geotagged, its GPS coordinates as a link that opens the location in your
@@ -59,6 +63,13 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Changed ### Changed
- **Two-column lightbox details** — the image info panel now lays metadata out in
two columns (Dimensions/Duration, Video codec/Audio codec, Type/File size sit
side by side), so the panel is more compact and less scrolling.
- **Faster Explore on large libraries** — revisiting a folder's visual clusters
is now near-instant. The cluster cache is validated from a lightweight
image-ID signature instead of re-reading every embedding, so big libraries no
longer stall for several seconds even when the cached result is reused.
- **Tag manager search and sort** — the Manage mode tag list now has a live - **Tag manager search and sort** — the Manage mode tag list now has a live
filter input and a sort dropdown (most-used / least-used / AZ / ZA). The filter input and a sort dropdown (most-used / least-used / AZ / ZA). The
list is virtualised so libraries with thousands of tags scroll without lag, list is virtualised so libraries with thousands of tags scroll without lag,
@@ -86,6 +97,10 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Fixed ### Fixed
- **Stale Explore results on folder switch** — switching folders (or re-entering
Explore) no longer briefly shows the previous folder's clusters/tags with no
loading indicator; the view now clears and shows a loading state until the new
folder's data arrives.
- **Rating no longer scrambles search results** — rating or favoriting an image - **Rating no longer scrambles search results** — rating or favoriting an image
while viewing similar-image, region, semantic, tag, or album results no longer while viewing similar-image, region, semantic, tag, or album results no longer
re-sorts the view back into the default order; the current result ordering is re-sorts the view back into the default order; the current result ordering is
+43 -30
View File
@@ -1178,38 +1178,39 @@ pub async fn get_tag_cloud(
db: State<'_, DbState>, db: State<'_, DbState>,
folder_id: Option<i64>, folder_id: Option<i64>,
) -> Result<Vec<TagCloudEntry>, String> { ) -> Result<Vec<TagCloudEntry>, String> {
let embeddings_with_ids = {
let conn = db.get().map_err(|e| e.to_string())?;
vector::get_all_image_embeddings_with_ids(&conn, folder_id).map_err(|e| e.to_string())?
};
let n = embeddings_with_ids.len();
if n < 5 {
return Ok(vec![]);
}
// Sort by ID for stable ordering; hash both IDs and embedding bytes so that
// replacing a file and regenerating its embedding invalidates the cache even
// when the set of image IDs hasn't changed.
let mut sorted_pairs: Vec<_> = embeddings_with_ids.iter().collect();
sorted_pairs.sort_unstable_by_key(|(id, _)| *id);
let current_hash = {
use xxhash_rust::xxh3::Xxh3;
let mut hasher = Xxh3::new();
for (id, embedding) in &sorted_pairs {
hasher.update(&id.to_le_bytes());
for val in embedding.iter() {
hasher.update(&val.to_le_bytes());
}
}
hasher.digest()
};
let folder_scope = match folder_id { let folder_scope = match folder_id {
Some(id) => format!("folder_{id}"), Some(id) => format!("folder_{id}"),
None => "all".to_string(), None => "all".to_string(),
}; };
// Try to return a valid SQLite cache // Build a cheap cache key from a hash of the embedded image-ID set plus the
// global monotonic embedding revision. The ID-set hash catches membership
// changes (add/remove, or moving an image between folders — even when the
// count is unchanged); the revision catches an image being re-embedded in
// place (same IDs, new vector). Together they validate the cache WITHOUT
// reading and unpacking every embedding blob — which for a large library is
// hundreds of MB and was the real cause of the multi-second Explore stall
// even on a cache hit.
let (count, current_hash) = {
let conn = db.get().map_err(|e| e.to_string())?;
let (count, ids_hash) =
vector::embedding_ids_signature(&conn, folder_id).map_err(|e| e.to_string())?;
let revision = vector::get_embedding_revision(&conn).map_err(|e| e.to_string())?;
let hash = {
use xxhash_rust::xxh3::Xxh3;
let mut hasher = Xxh3::new();
hasher.update(&ids_hash.to_le_bytes());
hasher.update(revision.as_bytes());
hasher.digest()
};
(count, hash)
};
if count < 5 {
return Ok(vec![]);
}
// Try to return a valid SQLite cache before loading any embeddings.
{ {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash) if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash)
@@ -1225,8 +1226,17 @@ pub async fn get_tag_cloud(
} }
} }
// Cache miss — run k-means // Cache miss — now pay the cost of loading embeddings and running k-means.
let embeddings_with_ids = {
let conn = db.get().map_err(|e| e.to_string())?;
vector::get_all_image_embeddings_with_ids(&conn, folder_id).map_err(|e| e.to_string())?
};
if embeddings_with_ids.len() < 5 {
return Ok(vec![]);
}
let ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect(); let ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect();
let n = embeddings_with_ids.len();
let points: Vec<Vec<f32>> = embeddings_with_ids let points: Vec<Vec<f32>> = embeddings_with_ids
.into_iter() .into_iter()
.map(|(_, emb)| emb) .map(|(_, emb)| emb)
@@ -1276,9 +1286,12 @@ pub async fn get_tag_cloud(
}); });
} }
// Persist to SQLite — ignore write errors (cache is best-effort) // Persist to SQLite. The cache is best-effort, but a silent write failure here
// would defeat the whole optimization (every visit would recompute), so log it.
if let Ok(json) = serde_json::to_string(&entries) { if let Ok(json) = serde_json::to_string(&entries) {
let _ = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json); if let Err(e) = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json) {
eprintln!("failed to persist tag-cloud cache for {folder_scope}: {e}");
}
} }
Ok(entries) Ok(entries)
+38
View File
@@ -263,6 +263,44 @@ pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
/// Returns all stored image embeddings with their image IDs, optionally filtered to one folder. /// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
/// Each entry is `(image_id, normalized_f32_embedding)`. /// Each entry is `(image_id, normalized_f32_embedding)`.
/// Returns `(count, hash)` over the stored embedding image IDs for the scope in a
/// single ordered pass, without loading any embedding blobs. The hash covers the
/// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an
/// image between folders changes it even when the count happens to stay the same.
/// Used (together with the embedding revision, which catches an image being
/// re-embedded in place) as the cheap tag-cloud cache key so a cache hit doesn't
/// have to read and unpack hundreds of MB of embeddings just to validate freshness.
pub fn embedding_ids_signature(conn: &Connection, folder_id: Option<i64>) -> Result<(i64, u64)> {
use xxhash_rust::xxh3::Xxh3;
let mut hasher = Xxh3::new();
let mut count: i64 = 0;
let mut hash_row = |id: i64| {
hasher.update(&id.to_le_bytes());
count += 1;
};
match folder_id {
Some(fid) => {
let mut stmt = conn.prepare(
"SELECT image_id FROM image_vec
WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1)
ORDER BY image_id",
)?;
let mut rows = stmt.query([fid])?;
while let Some(row) = rows.next()? {
hash_row(row.get(0)?);
}
}
None => {
let mut stmt = conn.prepare("SELECT image_id FROM image_vec ORDER BY image_id")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
hash_row(row.get(0)?);
}
}
}
Ok((count, hasher.digest()))
}
pub fn get_all_image_embeddings_with_ids( pub fn get_all_image_embeddings_with_ids(
conn: &Connection, conn: &Connection,
folder_id: Option<i64>, folder_id: Option<i64>,
+6 -4
View File
@@ -611,7 +611,8 @@ export function Lightbox() {
)} )}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm"> <div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm">
<div> <div className="grid grid-cols-2 gap-x-6 gap-y-4">
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p> <p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, index) => { {Array.from({ length: 5 }, (_, index) => {
@@ -674,7 +675,7 @@ export function Lightbox() {
</div> </div>
{selectedImage.metadata_error ? ( {selectedImage.metadata_error ? (
<div> <div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Metadata</p> <p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Metadata</p>
<p className="text-amber-300">{selectedImage.metadata_error}</p> <p className="text-amber-300">{selectedImage.metadata_error}</p>
</div> </div>
@@ -692,18 +693,19 @@ export function Lightbox() {
<p className="text-white">{formatBytes(selectedImage.file_size)}</p> <p className="text-white">{formatBytes(selectedImage.file_size)}</p>
</div> </div>
<div> <div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Modified</p> <p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Modified</p>
<p className="text-white">{formatDate(selectedImage.modified_at)}</p> <p className="text-white">{formatDate(selectedImage.modified_at)}</p>
</div> </div>
<div> <div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Embedding</p> <p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Embedding</p>
<p className="text-white">{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}</p> <p className="text-white">{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}</p>
{selectedImage.embedding_error ? ( {selectedImage.embedding_error ? (
<p className="mt-1 text-xs text-amber-300">{selectedImage.embedding_error}</p> <p className="mt-1 text-xs text-amber-300">{selectedImage.embedding_error}</p>
) : null} ) : null}
</div> </div>
</div>
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
+12
View File
@@ -247,6 +247,7 @@ export function SettingsModal() {
const openOnboarding = useGalleryStore((state) => state.openOnboarding); const openOnboarding = useGalleryStore((state) => state.openOnboarding);
const theme = useGalleryStore((state) => state.theme); const theme = useGalleryStore((state) => state.theme);
const setTheme = useGalleryStore((state) => state.setTheme); const setTheme = useGalleryStore((state) => state.setTheme);
const openTagManager = useGalleryStore((state) => state.openTagManager);
const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay); const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay);
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay); const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute); const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
@@ -686,6 +687,17 @@ export function SettingsModal() {
{taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null} {taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Tag library" description="Review and clean up the tags across your library.">
<SettingsItem label="Manage tags" description="Open the tag manager in Explore to search, rename, and delete tags.">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={openTagManager}
>
Open tag manager
</button>
</SettingsItem>
</SettingsGroup>
</div> </div>
) : ( ) : (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
+4 -2
View File
@@ -983,7 +983,9 @@ export function TagCloud() {
const renameTag = useGalleryStore((state) => state.renameTag); const renameTag = useGalleryStore((state) => state.renameTag);
const deleteTag = useGalleryStore((state) => state.deleteTag); const deleteTag = useGalleryStore((state) => state.deleteTag);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const [manageTags, setManageTags] = useState(false); // Manage mode lives in the store so it can be opened from elsewhere (Settings).
const manageTags = useGalleryStore((state) => state.tagManagerOpen);
const setManageTags = useGalleryStore((state) => state.setTagManagerOpen);
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); }; const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
useEffect(() => { useEffect(() => {
@@ -1028,7 +1030,7 @@ export function TagCloud() {
? "border-white/15 bg-white/10 text-white" ? "border-white/15 bg-white/10 text-white"
: "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300" : "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300"
}`} }`}
onClick={() => setManageTags((v) => !v)} onClick={() => setManageTags(!manageTags)}
> >
{manageTags ? "Done" : "Manage"} {manageTags ? "Done" : "Manage"}
</button> </button>
+71 -4
View File
@@ -1,9 +1,15 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore } from "../store"; import { useGalleryStore, AppTheme } from "../store";
import { PhokusMark } from "./PhokusMark"; import { PhokusMark } from "./PhokusMark";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
const THEME_OPTIONS: { value: AppTheme; label: string }[] = [
{ value: "phokus", label: "Phokus" },
{ value: "subtle-light", label: "Subtle Light" },
{ value: "conventional-dark", label: "Conventional Dark" },
];
// SVG icons for window controls // SVG icons for window controls
function MinimizeIcon() { function MinimizeIcon() {
return ( return (
@@ -41,11 +47,40 @@ function CloseIcon() {
export function TitleBar() { export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen); const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const theme = useGalleryStore((state) => state.theme);
const setTheme = useGalleryStore((state) => state.setTheme);
const updateStatus = useGalleryStore((state) => state.updateStatus); const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion); const updateVersion = useGalleryStore((state) => state.updateVersion);
const installUpdate = useGalleryStore((state) => state.installUpdate); const installUpdate = useGalleryStore((state) => state.installUpdate);
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
// Right-clicking the settings cog opens a quick theme switcher, anchored under
// the cog so it never overflows the right edge of the window.
const settingsBtnRef = useRef<HTMLButtonElement>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
const [themeMenu, setThemeMenu] = useState<{ top: number; right: number } | null>(null);
useEffect(() => {
if (!themeMenu) return;
const handleDown = (e: MouseEvent) => {
if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) setThemeMenu(null);
};
const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") setThemeMenu(null); };
document.addEventListener("mousedown", handleDown);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("keydown", handleKey);
};
}, [themeMenu]);
const handleSettingsContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const rect = settingsBtnRef.current?.getBoundingClientRect();
if (!rect) return;
setThemeMenu({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
};
useEffect(() => { useEffect(() => {
// Get initial maximized state // Get initial maximized state
appWindow.isMaximized().then(setIsMaximized); appWindow.isMaximized().then(setIsMaximized);
@@ -109,9 +144,11 @@ export function TitleBar() {
className="flex items-stretch h-full" className="flex items-stretch h-full"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
> >
<Tooltip label="Settings (right-click to switch theme)" anchorToCursor>
<button <button
ref={settingsBtnRef}
onClick={() => setSettingsOpen(true)} onClick={() => setSettingsOpen(true)}
title="Settings" onContextMenu={handleSettingsContextMenu}
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300" className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -119,7 +156,7 @@ export function TitleBar() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
</button> </button>
</Tooltip>
{/* Minimize */} {/* Minimize */}
<button <button
onClick={handleMinimize} onClick={handleMinimize}
@@ -147,6 +184,36 @@ export function TitleBar() {
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
{/* Quick theme switcher — opened by right-clicking the settings cog. */}
{themeMenu && (
<div
ref={themeMenuRef}
className="fixed z-50 min-w-[170px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
style={{ top: themeMenu.top, right: themeMenu.right, WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<div className="px-3 pt-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-500">Theme</div>
{THEME_OPTIONS.map((opt) => {
const active = theme === opt.value;
return (
<button
key={opt.value}
className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-1.5 text-left text-[12px] transition-colors ${
active ? "bg-white/8 text-white" : "text-gray-300 hover:bg-white/8 hover:text-white"
}`}
onClick={() => { setTheme(opt.value); setThemeMenu(null); }}
>
{opt.label}
{active && (
<svg className="h-3.5 w-3.5 text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
)}
</div> </div>
); );
} }
+54 -4
View File
@@ -375,12 +375,20 @@ interface GalleryState {
galleryScrollResetKey: number; galleryScrollResetKey: number;
activeView: ActiveView; activeView: ActiveView;
exploreMode: ExploreMode; exploreMode: ExploreMode;
tagManagerOpen: boolean;
tagCloudEntries: TagCloudEntry[]; tagCloudEntries: TagCloudEntry[];
tagCloudLoading: boolean; tagCloudLoading: boolean;
tagCloudFolderId: number | null | undefined; // undefined = never loaded tagCloudFolderId: number | null | undefined; // undefined = never loaded
exploreTagEntries: ExploreTagEntry[]; exploreTagEntries: ExploreTagEntry[];
exploreTagLoading: boolean; exploreTagLoading: boolean;
// Cache-freshness key: the folder the loaded tags belong to. Set to undefined
// by content mutations (tag add/remove/rename/delete) to mark the cache dirty
// and force the next load to refetch.
exploreTagsFolderId: number | null | undefined; exploreTagsFolderId: number | null | undefined;
// The folder whose tags are actually on screen. Kept separate from the dirty
// marker above so a same-folder invalidation isn't mistaken for a folder switch
// (which would wipe the visible list and remount manager UI mid-refresh).
exploreTagsShownFolderId: number | null | undefined;
relatedTagsByKey: Record<string, RelatedTagEntry[]>; relatedTagsByKey: Record<string, RelatedTagEntry[]>;
indexingProgress: Record<number, IndexProgress>; indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>; mediaJobProgress: Record<number, FolderJobProgress>;
@@ -487,6 +495,8 @@ interface GalleryState {
closeImage: () => void; closeImage: () => void;
setView: (view: ActiveView) => void; setView: (view: ActiveView) => void;
setExploreMode: (mode: ExploreMode) => void; setExploreMode: (mode: ExploreMode) => void;
setTagManagerOpen: (open: boolean) => void;
openTagManager: () => void;
loadTagCloud: (options?: { force?: boolean }) => Promise<void>; loadTagCloud: (options?: { force?: boolean }) => Promise<void>;
loadExploreTags: (options?: { force?: boolean }) => Promise<void>; loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>; loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
@@ -867,12 +877,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
galleryScrollResetKey: 0, galleryScrollResetKey: 0,
activeView: "gallery", activeView: "gallery",
exploreMode: "visual", exploreMode: "visual",
tagManagerOpen: false,
tagCloudEntries: [], tagCloudEntries: [],
tagCloudLoading: false, tagCloudLoading: false,
tagCloudFolderId: undefined, tagCloudFolderId: undefined,
exploreTagEntries: [], exploreTagEntries: [],
exploreTagLoading: false, exploreTagLoading: false,
exploreTagsFolderId: undefined, exploreTagsFolderId: undefined,
exploreTagsShownFolderId: undefined,
relatedTagsByKey: {}, relatedTagsByKey: {},
indexingProgress: {}, indexingProgress: {},
mediaJobProgress: {}, mediaJobProgress: {},
@@ -1391,10 +1403,27 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return; return;
} }
} }
set({ activeView, similarSourceAlbumId: null, similarScope: similarScopeReset }); // Entering Explore normally always starts in browse mode; openTagManager() is
// the only path that re-opens manage mode (it runs this then sets the flag).
set({
activeView,
similarSourceAlbumId: null,
similarScope: similarScopeReset,
...(activeView === "explore" ? { tagManagerOpen: false } : {}),
});
}, },
setExploreMode: (exploreMode) => set({ exploreMode }), setExploreMode: (exploreMode) =>
// Manage mode only exists for the tag view; drop it when switching to visual
// clusters so re-entering the tag view starts in the normal browse state.
set(exploreMode === "visual" ? { exploreMode, tagManagerOpen: false } : { exploreMode }),
setTagManagerOpen: (tagManagerOpen) => set({ tagManagerOpen }),
// Jump straight to the tag manager from anywhere (e.g. the Settings panel):
// switch to Explore, select the tag view, open manage mode, and close Settings.
openTagManager: () => {
get().setView("explore");
set({ exploreMode: "tags", tagManagerOpen: true, settingsOpen: false });
},
loadTagCloud: async (options) => { loadTagCloud: async (options) => {
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get(); const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
@@ -1404,7 +1433,15 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return; return;
} }
const requestToken = ++tagCloudRequestToken; const requestToken = ++tagCloudRequestToken;
set({ tagCloudLoading: true, tagCloudFolderId: selectedFolderId }); // On a real folder switch, drop the previous folder's clusters so the loading
// panel shows instead of lingering stale results. A same-folder refresh keeps
// them to avoid a flicker when the cache returns instantly.
const isFolderSwitch = tagCloudFolderId !== selectedFolderId;
set({
tagCloudLoading: true,
tagCloudFolderId: selectedFolderId,
...(isFolderSwitch ? { tagCloudEntries: [] } : {}),
});
try { try {
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", { const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
folderId: selectedFolderId, folderId: selectedFolderId,
@@ -1425,7 +1462,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return; return;
} }
const requestToken = ++exploreTagRequestToken; const requestToken = ++exploreTagRequestToken;
set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId }); // A real folder switch is decided by what's currently *shown*, not by the
// dirty marker — a same-folder invalidation nulls exploreTagsFolderId but
// leaves exploreTagsShownFolderId pointing at the displayed folder, so the
// visible list (and manager UI state) survives the refresh. On an actual
// switch, drop the previous folder's tags so the loading panel shows.
const { exploreTagsShownFolderId } = get();
const isFolderSwitch =
exploreTagsShownFolderId !== undefined && exploreTagsShownFolderId !== selectedFolderId;
set({
exploreTagLoading: true,
exploreTagsFolderId: selectedFolderId,
exploreTagsShownFolderId: selectedFolderId,
...(isFolderSwitch ? { exploreTagEntries: [], relatedTagsByKey: {} } : {}),
});
try { try {
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", { const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
params: { folder_id: selectedFolderId, limit: 180 }, params: { folder_id: selectedFolderId, limit: 180 },