Compare commits
4 Commits
23e9850c7a
...
68a9df5ab3
| Author | SHA1 | Date | |
|---|---|---|---|
| 68a9df5ab3 | |||
| 79ce458fd5 | |||
| a9a8f8422e | |||
| ab7022e118 |
+16
-1
@@ -9,6 +9,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
|
||||
### 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
|
||||
(with cover thumbnails, kept visually distinct from Libraries) lets you create,
|
||||
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
|
||||
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
|
||||
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, lens, aperture, shutter, ISO, focal length) and, when a photo is
|
||||
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
|
||||
|
||||
- **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
|
||||
filter input and a sort dropdown (most-used / least-used / A–Z / Z–A). The
|
||||
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
|
||||
|
||||
- **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
|
||||
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
|
||||
|
||||
+43
-30
@@ -1178,38 +1178,39 @@ pub async fn get_tag_cloud(
|
||||
db: State<'_, DbState>,
|
||||
folder_id: Option<i64>,
|
||||
) -> 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 {
|
||||
Some(id) => format!("folder_{id}"),
|
||||
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())?;
|
||||
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 n = embeddings_with_ids.len();
|
||||
let points: Vec<Vec<f32>> = embeddings_with_ids
|
||||
.into_iter()
|
||||
.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) {
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
/// 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(
|
||||
conn: &Connection,
|
||||
folder_id: Option<i64>,
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }, (_, index) => {
|
||||
@@ -674,7 +675,7 @@ export function Lightbox() {
|
||||
</div>
|
||||
|
||||
{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="text-amber-300">{selectedImage.metadata_error}</p>
|
||||
</div>
|
||||
@@ -692,18 +693,19 @@ export function Lightbox() {
|
||||
<p className="text-white">{formatBytes(selectedImage.file_size)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="col-span-2">
|
||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Modified</p>
|
||||
<p className="text-white">{formatDate(selectedImage.modified_at)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="col-span-2">
|
||||
<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>
|
||||
{selectedImage.embedding_error ? (
|
||||
<p className="mt-1 text-xs text-amber-300">{selectedImage.embedding_error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
|
||||
@@ -247,6 +247,7 @@ export function SettingsModal() {
|
||||
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
|
||||
const theme = useGalleryStore((state) => state.theme);
|
||||
const setTheme = useGalleryStore((state) => state.setTheme);
|
||||
const openTagManager = useGalleryStore((state) => state.openTagManager);
|
||||
const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay);
|
||||
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
|
||||
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}
|
||||
</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 className="mt-8 space-y-9">
|
||||
|
||||
@@ -983,7 +983,9 @@ export function TagCloud() {
|
||||
const renameTag = useGalleryStore((state) => state.renameTag);
|
||||
const deleteTag = useGalleryStore((state) => state.deleteTag);
|
||||
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); };
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1028,7 +1030,7 @@ export function TagCloud() {
|
||||
? "border-white/15 bg-white/10 text-white"
|
||||
: "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setManageTags((v) => !v)}
|
||||
onClick={() => setManageTags(!manageTags)}
|
||||
>
|
||||
{manageTags ? "Done" : "Manage"}
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useGalleryStore } from "../store";
|
||||
import { useGalleryStore, AppTheme } from "../store";
|
||||
import { PhokusMark } from "./PhokusMark";
|
||||
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
|
||||
function MinimizeIcon() {
|
||||
return (
|
||||
@@ -41,11 +47,40 @@ function CloseIcon() {
|
||||
export function TitleBar() {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
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 updateVersion = useGalleryStore((state) => state.updateVersion);
|
||||
const installUpdate = useGalleryStore((state) => state.installUpdate);
|
||||
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(() => {
|
||||
// Get initial maximized state
|
||||
appWindow.isMaximized().then(setIsMaximized);
|
||||
@@ -109,9 +144,11 @@ export function TitleBar() {
|
||||
className="flex items-stretch h-full"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<Tooltip label="Settings (right-click to switch theme)" anchorToCursor>
|
||||
<button
|
||||
ref={settingsBtnRef}
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
</Tooltip>
|
||||
{/* Minimize */}
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
@@ -147,6 +184,36 @@ export function TitleBar() {
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+54
-4
@@ -375,12 +375,20 @@ interface GalleryState {
|
||||
galleryScrollResetKey: number;
|
||||
activeView: ActiveView;
|
||||
exploreMode: ExploreMode;
|
||||
tagManagerOpen: boolean;
|
||||
tagCloudEntries: TagCloudEntry[];
|
||||
tagCloudLoading: boolean;
|
||||
tagCloudFolderId: number | null | undefined; // undefined = never loaded
|
||||
exploreTagEntries: ExploreTagEntry[];
|
||||
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;
|
||||
// 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[]>;
|
||||
indexingProgress: Record<number, IndexProgress>;
|
||||
mediaJobProgress: Record<number, FolderJobProgress>;
|
||||
@@ -487,6 +495,8 @@ interface GalleryState {
|
||||
closeImage: () => void;
|
||||
setView: (view: ActiveView) => void;
|
||||
setExploreMode: (mode: ExploreMode) => void;
|
||||
setTagManagerOpen: (open: boolean) => void;
|
||||
openTagManager: () => void;
|
||||
loadTagCloud: (options?: { force?: boolean }) => Promise<void>;
|
||||
loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
|
||||
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
|
||||
@@ -867,12 +877,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
galleryScrollResetKey: 0,
|
||||
activeView: "gallery",
|
||||
exploreMode: "visual",
|
||||
tagManagerOpen: false,
|
||||
tagCloudEntries: [],
|
||||
tagCloudLoading: false,
|
||||
tagCloudFolderId: undefined,
|
||||
exploreTagEntries: [],
|
||||
exploreTagLoading: false,
|
||||
exploreTagsFolderId: undefined,
|
||||
exploreTagsShownFolderId: undefined,
|
||||
relatedTagsByKey: {},
|
||||
indexingProgress: {},
|
||||
mediaJobProgress: {},
|
||||
@@ -1391,10 +1403,27 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
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) => {
|
||||
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
|
||||
@@ -1404,7 +1433,15 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
|
||||
folderId: selectedFolderId,
|
||||
@@ -1425,7 +1462,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
|
||||
params: { folder_id: selectedFolderId, limit: 180 },
|
||||
|
||||
Reference in New Issue
Block a user