Compare commits
4 Commits
| 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
|
### 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 / A–Z / Z–A). The
|
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,
|
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
@@ -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)
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user