diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d8669..1598965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ All notable changes to Phokus are documented here. The format is based on aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (0.x: anything may change between minor versions). +## [Unreleased] + +### Added + +- **Timeline scrubber** — a year/month rail on the Timeline view that jumps to + any period in the library. Timeline now loads the full filtered set so the + scrubber spans the whole library instead of just the first page. +- **Folder reordering** in the sidebar — drag-and-drop (with edge auto-scroll) + or keyboard (↑/↓ on the drag handle), with the custom order persisted across + sessions; the Libraries list also gains A–Z / Z–A / Custom sort. +- Failed AI-tagging jobs can now be located from the background worker prompt, + including a gallery filter for images with failed tags and an expanded list + of failed filenames/errors. +- A new theme system adds Phokus, Subtle Light, and Conventional Dark chrome + options across the app. +- First-run onboarding now includes an inline theme picker so new users can + choose their preferred app chrome before continuing the tour. + +### Changed + +- The gallery grid is now row-virtualised, so very large libraries scroll + smoothly and only on-screen thumbnails are rendered. +- Polished the new theme surfaces before release, including readable + subtle-light secondary buttons, failed-worker action buttons, and onboarding + controls. +- Onboarding preview media keeps the dark gallery/media surface regardless of + the active chrome theme. + +### Fixed + +- Video embedding jobs are no longer claimed before their thumbnail exists, and + any that previously failed for that reason are requeued on startup — videos no + longer churn through failed embeddings. +- Subtle Light theme consistency — the lightbox metadata panel now follows the + light chrome while the image canvas stays dark (matching Conventional Dark), + and gallery/timeline media badges, duplicate-finder thumbnails, and the window + restore icon now theme correctly instead of staying Phokus-dark. +- Timeline scrolling is now smooth on large libraries — it virtualizes per row + of tiles instead of per month, so a month with thousands of photos no longer + mounts every tile at once (thumbnails now load in incrementally as you scroll, + matching the All Media grid). +- Background worker updates (thumbnails, metadata, embeddings, tags) no longer + re-sort the entire loaded image set on every batch. In Timeline, which loads + the whole library, this re-sort caused severe lag and could crash the app + during background indexing. + ## [0.1.0] — 2026-06-14 First public release. Windows desktop, distributed as an unsigned NSIS diff --git a/README.md b/README.md index 457480a..6fbed01 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ A local-first desktop media library for browsing, filtering, and curating image | Images | Videos | |--------|--------| | jpg, jpeg, png, gif, bmp | mp4, mov, m4v | -| tiff, tif, webp, avif, heic, heif | webm | +| tiff, tif, webp, avif | webm | ## Installation diff --git a/package.json b/package.json index 4267de3..bda87fe 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dev:web": "cd website && pnpm dev", "build:app:cpu": "tauri build -- --no-default-features", "build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json", + "changelog:add": "node tools/changelog-add.mjs", "dev:vite": "vite", "preview": "vite preview", "tauri": "tauri" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 77cd5ab..de4f768 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -38,6 +38,7 @@ pub struct GetImagesParams { pub favorites_only: Option, pub rating_min: Option, pub embedding_failed_only: Option, + pub tagging_failed_only: Option, pub sort: Option, pub offset: Option, pub limit: Option, @@ -267,6 +268,20 @@ pub async fn get_folders(db: State<'_, DbState>) -> Result, String> db::get_folders(&conn).map_err(|e| e.to_string()) } +#[derive(Deserialize)] +pub struct ReorderFoldersParams { + pub folder_ids: Vec, +} + +#[tauri::command] +pub async fn reorder_folders( + db: State<'_, DbState>, + params: ReorderFoldersParams, +) -> Result<(), String> { + let conn = db.get().map_err(|e| e.to_string())?; + db::reorder_folders(&conn, ¶ms.folder_ids).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn get_background_job_progress( db: State<'_, DbState>, @@ -315,6 +330,7 @@ pub async fn get_images( let favorites_only = params.favorites_only.unwrap_or(false); let rating_min = params.rating_min.unwrap_or(0); let embedding_failed_only = params.embedding_failed_only.unwrap_or(false); + let tagging_failed_only = params.tagging_failed_only.unwrap_or(false); let total = db::count_images( &conn, @@ -324,6 +340,7 @@ pub async fn get_images( favorites_only, rating_min, embedding_failed_only, + tagging_failed_only, ) .map_err(|e| e.to_string())?; @@ -335,6 +352,7 @@ pub async fn get_images( favorites_only, rating_min, embedding_failed_only, + tagging_failed_only, sort, offset, limit, @@ -580,6 +598,34 @@ pub async fn retry_failed_embeddings( db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string()) } +#[derive(Serialize)] +pub struct FailedImageItem { + pub image_id: i64, + pub filename: String, + pub path: String, + pub error: Option, +} + +#[tauri::command] +pub async fn get_failed_tagging_images( + db: State<'_, DbState>, + folder_id: i64, +) -> Result, String> { + let conn = db.get().map_err(|e| e.to_string())?; + db::get_failed_tagging_images(&conn, folder_id) + .map(|rows| { + rows.into_iter() + .map(|(image_id, filename, path, error)| FailedImageItem { + image_id, + filename, + path, + error, + }) + .collect() + }) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn semantic_search_images( db: State<'_, DbState>, @@ -1468,6 +1514,7 @@ fn kmeans_cosine( pub struct FailedEmbeddingItem { pub image_id: i64, pub filename: String, + pub path: String, pub error: Option, } @@ -1480,9 +1527,10 @@ pub async fn get_failed_embedding_images( let rows = db::get_failed_embedding_images(&conn, folder_id).map_err(|e| e.to_string())?; Ok(rows .into_iter() - .map(|(image_id, filename, error)| FailedEmbeddingItem { + .map(|(image_id, filename, path, error)| FailedEmbeddingItem { image_id, filename, + path, error, }) .collect()) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f62d823..ccb8884 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -31,6 +31,7 @@ pub struct Folder { pub image_count: i64, pub indexed_at: Option, pub scan_error: Option, + pub sort_order: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -322,6 +323,11 @@ pub fn migrate(conn: &Connection) -> Result<()> { // on databases that predate Phase 1. conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?; ensure_column(conn, "folders", "scan_error", "TEXT")?; + ensure_column(conn, "folders", "sort_order", "INTEGER NOT NULL DEFAULT 0")?; + conn.execute( + "UPDATE folders SET sort_order = id WHERE sort_order = 0", + [], + )?; vector::migrate(conn)?; Ok(()) @@ -329,7 +335,8 @@ pub fn migrate(conn: &Connection) -> Result<()> { pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result { conn.execute( - "INSERT OR IGNORE INTO folders (path, name) VALUES (?1, ?2)", + "INSERT OR IGNORE INTO folders (path, name, sort_order) + VALUES (?1, ?2, COALESCE((SELECT MAX(sort_order) + 1 FROM folders), 1))", params![path, name], )?; let id: i64 = conn.query_row( @@ -814,6 +821,7 @@ pub fn get_pending_embedding_jobs( FROM embedding_jobs j JOIN images i ON i.id = j.image_id WHERE status = 'pending' + AND NOT (i.media_kind = 'video' AND i.thumbnail_path IS NULL) {} ORDER BY j.updated_at, j.image_id LIMIT ?1", @@ -1224,7 +1232,12 @@ pub fn has_claimable_embedding_jobs( conn: &Connection, excluded_folder_ids: &std::collections::HashSet, ) -> Result { - has_claimable_jobs(conn, "embedding_jobs", "", excluded_folder_ids) + has_claimable_jobs( + conn, + "embedding_jobs", + "AND NOT (i.media_kind = 'video' AND i.thumbnail_path IS NULL)", + excluded_folder_ids, + ) } pub fn claim_thumbnail_jobs( @@ -1507,7 +1520,9 @@ pub fn get_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result Result> { let mut stmt = conn.prepare( - "SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name", + "SELECT id, path, name, image_count, indexed_at, scan_error, sort_order + FROM folders + ORDER BY sort_order, id", )?; let rows = stmt.query_map([], |row| { Ok(Folder { @@ -1517,11 +1532,47 @@ pub fn get_folders(conn: &Connection) -> Result> { image_count: row.get(3)?, indexed_at: row.get(4)?, scan_error: row.get(5)?, + sort_order: row.get(6)?, }) })?; Ok(rows.collect::>>()?) } +pub fn reorder_folders(conn: &Connection, folder_ids: &[i64]) -> Result<()> { + let tx = conn.unchecked_transaction()?; + for (index, folder_id) in folder_ids.iter().enumerate() { + tx.execute( + "UPDATE folders SET sort_order = ?2 WHERE id = ?1", + params![folder_id, index as i64 + 1], + )?; + } + tx.commit()?; + Ok(()) +} + +pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result { + let pattern = "No thumbnail available yet for%"; + let repaired = conn.execute( + "UPDATE embedding_jobs + SET status = 'pending', last_error = NULL, updated_at = datetime('now') + WHERE status = 'failed' + AND last_error LIKE ?1 + AND image_id IN ( + SELECT id FROM images WHERE media_kind = 'video' + )", + [pattern], + )?; + conn.execute( + "UPDATE images + SET embedding_status = 'pending', embedding_error = NULL + WHERE embedding_status = 'failed' + AND embedding_error LIKE ?1 + AND media_kind = 'video'", + [pattern], + )?; + Ok(repaired) +} + pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Result<()> { conn.execute( "UPDATE folders SET name = ?2 WHERE id = ?1", @@ -1581,6 +1632,7 @@ pub fn get_images( favorites_only: bool, rating_min: i64, embedding_failed_only: bool, + tagging_failed_only: bool, sort: &str, offset: i64, limit: i64, @@ -1604,6 +1656,7 @@ pub fn get_images( let search_pattern = search.map(|value| format!("%{value}%")); let favorites_flag = i64::from(favorites_only); let embedding_failed_flag = i64::from(embedding_failed_only); + let tagging_failed_flag = i64::from(tagging_failed_only); let sql = format!( "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, @@ -1617,8 +1670,9 @@ pub fn get_images( AND (?4 = 0 OR favorite = 1) AND rating >= ?5 AND (?6 = 0 OR embedding_status = 'failed') + AND (?7 = 0 OR ai_tagger_error IS NOT NULL) ORDER BY {order} - LIMIT ?7 OFFSET ?8" + LIMIT ?8 OFFSET ?9" ); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map( @@ -1629,6 +1683,7 @@ pub fn get_images( favorites_flag, rating_min, embedding_failed_flag, + tagging_failed_flag, limit, offset ], @@ -1645,11 +1700,13 @@ pub fn count_images( favorites_only: bool, rating_min: i64, embedding_failed_only: bool, + tagging_failed_only: bool, ) -> Result { let search_pattern = search.map(|value| format!("%{value}%")); let favorites_flag = i64::from(favorites_only); let embedding_failed_flag = i64::from(embedding_failed_only); + let tagging_failed_flag = i64::from(tagging_failed_only); let count = conn.query_row( "SELECT COUNT(*) FROM images WHERE (?1 IS NULL OR folder_id = ?1) @@ -1657,14 +1714,16 @@ pub fn count_images( AND (?3 IS NULL OR media_kind = ?3) AND (?4 = 0 OR favorite = 1) AND rating >= ?5 - AND (?6 = 0 OR embedding_status = 'failed')", + AND (?6 = 0 OR embedding_status = 'failed') + AND (?7 = 0 OR ai_tagger_error IS NOT NULL)", params![ folder_id, search_pattern, media_kind, favorites_flag, rating_min, - embedding_failed_flag + embedding_failed_flag, + tagging_failed_flag ], |row| row.get(0), )?; @@ -1876,12 +1935,14 @@ pub fn get_explore_tags( Ok(rows) } +type FailedEmbeddingRow = (i64, String, String, Option); + pub fn get_failed_embedding_images( conn: &Connection, folder_id: i64, -) -> Result)>> { +) -> Result> { let mut stmt = conn.prepare( - "SELECT id, filename, embedding_error + "SELECT id, filename, path, embedding_error FROM images WHERE folder_id = ?1 AND embedding_status = 'failed' ORDER BY filename", @@ -1890,7 +1951,32 @@ pub fn get_failed_embedding_images( Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + })?; + Ok(rows.collect::>>()?) +} + +type FailedTaggingRow = (i64, String, String, Option); + +pub fn get_failed_tagging_images( + conn: &Connection, + folder_id: i64, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT i.id, i.filename, i.path, COALESCE(j.last_error, i.ai_tagger_error) + FROM images i + LEFT JOIN tagging_jobs j ON j.image_id = i.id AND j.status = 'failed' + WHERE i.folder_id = ?1 AND i.ai_tagger_error IS NOT NULL + ORDER BY i.filename", + )?; + let rows = stmt.query_map([folder_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, )) })?; Ok(rows.collect::>>()?) diff --git a/src-tauri/src/embedder.rs b/src-tauri/src/embedder.rs index 7f82ce2..02c23b2 100644 --- a/src-tauri/src/embedder.rs +++ b/src-tauri/src/embedder.rs @@ -221,8 +221,8 @@ fn load_images(paths: &[PathBuf], image_size: usize) -> Result { /// Returns the path that should be fed to the CLIP image embedder for a given media file. /// /// For videos the thumbnail image is used (because CLIP only understands still images). -/// If a video has no thumbnail yet, an error is returned — the caller should mark the -/// embedding job as failed rather than trying to decode the raw video file. +/// Video jobs without thumbnails are excluded when jobs are claimed. The error remains +/// as a guard against a race where a thumbnail disappears after a job is claimed. pub fn embedding_source_path( path: &str, thumbnail_path: Option<&str>, diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 8233348..4779ca6 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -18,7 +18,7 @@ use tauri::{AppHandle, Emitter}; use walkdir::WalkDir; const IMAGE_EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif", "heic", "heif", + "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif", ]; const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"]; @@ -550,6 +550,9 @@ fn build_record( let filename = path.file_name()?.to_string_lossy().to_string(); let metadata = std::fs::metadata(path).ok()?; let file_size = metadata.len() as i64; + if file_size == 0 { + return None; + } let modified_at = metadata.modified().ok().map(|time| { let date_time: chrono::DateTime = time.into(); date_time.to_rfc3339() @@ -869,14 +872,14 @@ fn process_embedding_batch( let embedder = embedder.as_ref().expect("embedder should be initialized"); let infer_started_at = Instant::now(); - // Resolve the source path for each job. Videos without a thumbnail produce an Err - // here — those jobs are marked failed immediately without going to the embedder. + // Resolve each source path. Video jobs without thumbnails are not claimable, so an + // error here represents a real race or missing thumbnail rather than normal deferral. let source_results: Vec> = jobs .iter() .map(|job| embedding_source_path(&job.path, job.thumbnail_path.as_deref(), &job.media_kind)) .collect(); - // Separate jobs with a valid source from those that fail early (e.g. video with no thumbnail). + // Separate jobs with a valid source from genuine early failures. let mut embeddable_indices: Vec = Vec::new(); let mut embeddable_paths: Vec = Vec::new(); // image_id -> early error message for jobs that cannot be embedded yet @@ -1372,7 +1375,6 @@ fn mime_for_ext(ext: &str) -> &'static str { "webp" => "image/webp", "tiff" | "tif" => "image/tiff", "avif" => "image/avif", - "heic" | "heif" => "image/heif", "mp4" | "m4v" => "video/mp4", "mov" => "video/quicktime", "webm" => "video/webm", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da34945..79a5379 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -65,6 +65,11 @@ pub fn run() { let conn = pool.get().expect("Failed to get connection for migration"); db::migrate(&conn).expect("Failed to run migrations"); db::reset_inflight_jobs(&conn).expect("Failed to reset inflight jobs"); + let repaired_deferred = db::repair_deferred_embedding_jobs(&conn) + .expect("Failed to repair deferred embedding jobs"); + if repaired_deferred > 0 { + log::info!("Requeued {repaired_deferred} deferred video embedding jobs."); + } let backfilled = db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs"); if backfilled > 0 { @@ -124,6 +129,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::add_folder, commands::get_folders, + commands::reorder_folders, commands::get_background_job_progress, commands::remove_folder, commands::get_images, @@ -156,6 +162,7 @@ pub fn run() { commands::get_explore_tags, commands::get_images_by_ids, commands::get_failed_embedding_images, + commands::get_failed_tagging_images, commands::get_tagger_model_status, commands::get_tagger_acceleration, commands::set_tagger_acceleration, diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index e0cd20a..39282ec 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { useGalleryStore, WorkerKey } from "../store"; const WORKER_FOR_STAGE: Record = { @@ -30,23 +31,52 @@ interface Task { snapshot: string; } -interface FailedEmbeddingItem { +interface FailedWorkerItem { image_id: number; filename: string; + path: string; error: string | null; } +function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) { + return ( +
+ + + +
+

{item.filename}

+ {item.error && ( +

{item.error}

+ )} +
+ +
+ ); +} + export function BackgroundTasks() { const folders = useGalleryStore((state) => state.folders); const indexingProgress = useGalleryStore((state) => state.indexingProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); + const showFailedTagging = useGalleryStore((state) => state.showFailedTagging); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const [expanded, setExpanded] = useState(false); const [dismissed, setDismissed] = useState>({}); - const [failedItems, setFailedItems] = useState>({}); + const [failedEmbeddingItems, setFailedEmbeddingItems] = useState>({}); + const [failedTaggingItems, setFailedTaggingItems] = useState>({}); const workerPaused = useGalleryStore((state) => state.workerPaused); const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); @@ -56,27 +86,43 @@ export function BackgroundTasks() { void loadWorkerStates(); }, [folders, loadWorkerStates]); - // Fetch failed embedding filenames whenever the expanded panel opens or failure counts change. - const failedCounts = useMemo( + // Fetch failed filenames whenever the expanded panel opens or failure counts change. + const failedEmbeddingCounts = useMemo( () => Object.fromEntries( Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]), ), [mediaJobProgress], ); + const failedTaggingCounts = useMemo( + () => + Object.fromEntries( + Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]), + ), + [mediaJobProgress], + ); useEffect(() => { if (!expanded) return; - for (const [folderId, count] of Object.entries(failedCounts)) { + for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) { if (count > 0) { - invoke("get_failed_embedding_images", { + invoke("get_failed_embedding_images", { folderId: Number(folderId), }) - .then((items) => setFailedItems((prev) => ({ ...prev, [folderId]: items }))) + .then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items }))) .catch(() => undefined); } } - }, [expanded, failedCounts]); + for (const [folderId, count] of Object.entries(failedTaggingCounts)) { + if (count > 0) { + invoke("get_failed_tagging_images", { + folderId: Number(folderId), + }) + .then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items }))) + .catch(() => undefined); + } + } + }, [expanded, failedEmbeddingCounts, failedTaggingCounts]); const isWorkerPaused = (folderId: number, worker: WorkerKey) => { return workerPaused[folderId]?.[worker] ?? false; @@ -302,14 +348,14 @@ export function BackgroundTasks() { key={stage.label} className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ stage.failed - ? "bg-amber-500/10 text-amber-400" + ? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700" : isPaused ? "bg-white/4 text-gray-600" : "bg-white/5 text-gray-400" }`} > {stage.label} - + {stage.detail} {workerKey && ( @@ -355,14 +401,27 @@ export function BackgroundTasks() { )} - {/* Retry (failed embeddings only) */} - {primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && ( - + {primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && ( +
+ {primary.hasFailedTagging ? ( + + ) : null} + +
)} {/* Expand chevron (only when multiple tasks) */} @@ -414,7 +473,7 @@ export function BackgroundTasks() { key={stage.label} className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ stage.failed - ? "bg-amber-500/10 text-amber-400" + ? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700" : isPaused ? "bg-white/4 text-gray-600" : "bg-white/5 text-gray-500" @@ -426,7 +485,7 @@ export function BackgroundTasks() { )} {stage.label} - + {stage.detail} {workerKey && ( @@ -465,15 +524,25 @@ export function BackgroundTasks() { {taskHasFailed && ( - +
+ {task.hasFailedTagging ? ( + + ) : null} + +
)} {task.id >= 0 && ( @@ -495,22 +564,18 @@ export function BackgroundTasks() {

)} - {/* Failed embedding file list */} - {taskHasFailed && failedItems[task.id] && failedItems[task.id].length > 0 && ( + {/* Failed worker file lists */} + {taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && (
- {failedItems[task.id].map((item) => ( -
- - - -
-

{item.filename}

- {item.error && ( -

{item.error}

- )} -
-
+ {failedEmbeddingItems[task.id].map((item) => ( + + ))} +
+ )} + {taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && ( +
+ {failedTaggingItems[task.id].map((item) => ( + ))}
)} diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index ce9c344..1ab85fe 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -71,7 +71,7 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) { return ( {open ? ( -
+
+

{selectedImage.path}

diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index b2779b9..52e42d4 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; +import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; import { FfmpegStatusRow } from "./onboarding/StepWelcome"; +import { ThemedDropdown } from "./ThemedDropdown"; type SettingsSection = "workspace" | "general"; @@ -18,9 +19,9 @@ function formatBytesShort(bytes: number): string { function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) { const className = tone === "ready" - ? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" + ? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700" : tone === "busy" - ? "border-sky-400/25 bg-sky-500/10 text-sky-300" + ? "border-sky-400/25 bg-sky-500/10 text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700" : "border-white/10 bg-white/[0.04] text-gray-500"; return {children}; @@ -88,8 +89,8 @@ function ScopeButton({ scope, current, onSelect, children }: { type="button" className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${ active - ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" - : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200" + ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700" + : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white" }`} onClick={() => onSelect(scope)} > @@ -110,8 +111,8 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }: type="button" className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${ active - ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" - : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200" + ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700" + : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white" }`} onClick={() => onSelect(acceleration)} > @@ -192,6 +193,8 @@ export function SettingsModal() { const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const installUpdate = useGalleryStore((state) => state.installUpdate); const openOnboarding = useGalleryStore((state) => state.openOnboarding); + const theme = useGalleryStore((state) => state.theme); + const setTheme = useGalleryStore((state) => state.setTheme); useEffect(() => { if (!settingsOpen) return; @@ -309,7 +312,7 @@ export function SettingsModal() { return (
setSettingsOpen(false)}>
event.stopPropagation()} >
) : (
+ + + setTheme(value as AppTheme)} + ariaLabel="App theme" + options={[ + { value: "phokus", label: "Phokus" }, + { value: "subtle-light", label: "Subtle Light" }, + { value: "conventional-dark", label: "Conventional Dark" }, + ]} + /> + + + {updateStatus === "available" ? ( ) : ( + ) : null} {isMissing ? ( @@ -278,7 +335,7 @@ function FolderItem({ onRemove={() => setConfirmingRemoval(true)} /> )} - + ); } @@ -290,6 +347,117 @@ export function Sidebar() { const selectFolder = useGalleryStore((state) => state.selectFolder); const activeView = useGalleryStore((state) => state.activeView); const setView = useGalleryStore((state) => state.setView); + const reorderFolders = useGalleryStore((state) => state.reorderFolders); + const [librarySort, setLibrarySortState] = useState(() => { + const saved = window.localStorage.getItem(LIBRARY_SORT_KEY); + return saved === "za" || saved === "custom" ? saved : "az"; + }); + const [customFolders, setCustomFolders] = useState(folders); + const [draggedFolderId, setDraggedFolderId] = useState(null); + const folderListRef = useRef(null); + const customFoldersRef = useRef(folders); + const pointerYRef = useRef(0); + const autoScrollFrameRef = useRef(null); + const keyboardPersistRef = useRef | null>(null); + + useEffect( + () => () => { + if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current); + }, + [], + ); + + useEffect(() => { + if (draggedFolderId !== null) return; + setCustomFolders(folders); + customFoldersRef.current = folders; + }, [folders, draggedFolderId]); + + useEffect(() => { + if (draggedFolderId === null) return; + + const handlePointerMove = (event: PointerEvent) => { + pointerYRef.current = event.clientY; + }; + + const autoScroll = () => { + const list = folderListRef.current; + if (list) { + const rect = list.getBoundingClientRect(); + const edgeSize = Math.min(64, rect.height * 0.2); + const topDistance = pointerYRef.current - rect.top; + const bottomDistance = rect.bottom - pointerYRef.current; + let velocity = 0; + + if (topDistance < edgeSize) { + velocity = -Math.pow((edgeSize - Math.max(0, topDistance)) / edgeSize, 1.6) * 14; + } else if (bottomDistance < edgeSize) { + velocity = Math.pow((edgeSize - Math.max(0, bottomDistance)) / edgeSize, 1.6) * 14; + } + + if (velocity !== 0) list.scrollTop += velocity; + } + autoScrollFrameRef.current = requestAnimationFrame(autoScroll); + }; + + window.addEventListener("pointermove", handlePointerMove, { passive: true }); + autoScrollFrameRef.current = requestAnimationFrame(autoScroll); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + if (autoScrollFrameRef.current !== null) cancelAnimationFrame(autoScrollFrameRef.current); + autoScrollFrameRef.current = null; + }; + }, [draggedFolderId]); + + const displayedFolders = useMemo(() => { + if (librarySort === "custom") return customFolders; + return [...folders].sort((a, b) => { + const result = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); + return librarySort === "az" ? result : -result; + }); + }, [customFolders, folders, librarySort]); + + const setLibrarySort = (sort: LibrarySort) => { + window.localStorage.setItem(LIBRARY_SORT_KEY, sort); + setLibrarySortState(sort); + }; + + const handleReorder = (orderedIds: number[]) => { + const byId = new Map(customFoldersRef.current.map((folder) => [folder.id, folder])); + const next = orderedIds + .map((id) => byId.get(id)) + .filter((folder): folder is Folder => folder !== undefined); + customFoldersRef.current = next; + setCustomFolders(next); + }; + + const finishReorder = () => { + const nextIds = customFoldersRef.current.map((folder) => folder.id); + setDraggedFolderId(null); + const currentIds = folders.map((folder) => folder.id); + if (nextIds.some((id, index) => id !== currentIds[index])) { + void reorderFolders(nextIds); + } + }; + + const moveFolderByKeyboard = (folderId: number, direction: -1 | 1) => { + const current = customFoldersRef.current; + const currentIndex = current.findIndex((folder) => folder.id === folderId); + const nextIndex = currentIndex + direction; + if (currentIndex < 0 || nextIndex < 0 || nextIndex >= current.length) return; + + const next = [...current]; + [next[currentIndex], next[nextIndex]] = [next[nextIndex], next[currentIndex]]; + customFoldersRef.current = next; + setCustomFolders(next); + // Debounce the DB write so a held arrow key doesn't fire one per keystroke; + // the local order updates immediately, only the persist waits to settle. + if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current); + keyboardPersistRef.current = setTimeout(() => { + keyboardPersistRef.current = null; + void reorderFolders(customFoldersRef.current.map((folder) => folder.id)); + }, 400); + }; const handleAddFolder = async () => { const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" }); @@ -387,28 +555,55 @@ export function Sidebar() { {/* Section label */} {folders.length > 0 && ( -
+
Libraries + setLibrarySort(value as LibrarySort)} + ariaLabel="Library order" + compact + options={[ + { value: "az", label: "A-Z" }, + { value: "za", label: "Z-A" }, + { value: "custom", label: "Custom" }, + ]} + />
)} {/* Folder list */} -
+ folder.id)} + onReorder={librarySort === "custom" ? handleReorder : () => {}} + layoutScroll + className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0" + > {folders.length === 0 ? (

Add a folder to get started

) : ( - folders.map((folder) => ( + displayedFolders.map((folder) => ( { + pointerYRef.current = pointerY; + setDraggedFolderId(folder.id); + }} + onDragEnd={finishReorder} + onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)} /> )) )} -
+ ); } diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index fffae2f..97ae8b3 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { motion } from "framer-motion"; +import { motion, useReducedMotion } from "framer-motion"; import { convertFileSrc } from "@tauri-apps/api/core"; import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; @@ -110,29 +110,44 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu })); } -function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: number[]) => void }) { +function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) { const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null; const { w, h, accent } = node; + const driftTransition = { + duration: node.driftDuration, + ease: "easeInOut" as const, + delay: seeded(node.index + 41) * 1.6, + repeat: 1, + repeatType: "reverse" as const, + }; return ( onOpen(node.entry.image_ids)} title={`Open cluster — ${node.entry.count.toLocaleString()} images`} @@ -143,22 +158,24 @@ function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: numb alt="" className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" draggable={false} + loading="lazy" + decoding="async" /> ) : (
)} -
+
{/* Accent glow on hover */}
-
+
-

Cluster

-

{node.entry.count.toLocaleString()}

+

Cluster

+

{node.entry.count.toLocaleString()}

onSearch(entry.tag)} title={`${entry.tag} — ${entry.count.toLocaleString()} images`} @@ -221,7 +238,7 @@ function TagWord({ function Spinner() { return ( @@ -238,6 +255,7 @@ function ClusterCloud({ entries: TagCloudEntry[]; onOpen: (imageIds: number[]) => void; }) { + const reducedMotion = useReducedMotion(); const canvasRef = useRef(null); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); @@ -261,9 +279,14 @@ function ClusterCloud({ return (
-
+
{nodes.map((node) => ( - + ))}
); @@ -300,13 +323,13 @@ export function TagCloud() { const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length; return ( -
+
{/* Header */} -
+
-

Explore

-

+

Explore

+

{loading ? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…" : hasEntries @@ -320,9 +343,9 @@ export function TagCloud() {

-
+
{loading ? ( -
+
{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}
) : !hasEntries ? (
-

+

{exploreMode === "visual" ? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar." : "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."} diff --git a/src/components/ThemedDropdown.tsx b/src/components/ThemedDropdown.tsx new file mode 100644 index 0000000..db8a833 --- /dev/null +++ b/src/components/ThemedDropdown.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef, useState } from "react"; + +export interface DropdownOption { + value: string; + label: string; +} + +export function ThemedDropdown({ + value, + options, + onChange, + ariaLabel, + compact = false, + align = "right", +}: { + value: string; + options: DropdownOption[]; + onChange: (value: string) => void; + ariaLabel: string; + compact?: boolean; + align?: "left" | "right"; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const current = options.find((option) => option.value === value) ?? options[0]; + + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (!ref.current?.contains(event.target as Node)) setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + return ( +

+ + + {open ? ( +
+ {options.map((option) => { + const selected = option.value === value; + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index edd4f6b..a2de2eb 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -5,6 +5,8 @@ import { ContextMenu, ImageTile } from "./Gallery"; const GAP = 6; const HEADER_HEIGHT = 52; +const SCRUBBER_WIDTH = 48; +const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const; interface TimelineGroup { key: string; @@ -12,6 +14,23 @@ interface TimelineGroup { images: ImageRecord[]; } +// One virtualized row: either a month header or a row of up to `cols` tiles. +type TimelineRow = + | { type: "header"; group: TimelineGroup } + | { type: "tiles"; images: ImageRecord[] }; + +interface ScrubberMonth { + monthNum: number; + label: string; + groupIndex: number; +} + +interface ScrubberYear { + year: string; + firstGroupIndex: number; + months: ScrubberMonth[]; +} + function buildLabel(key: string): string { if (key === "unknown") return "Unknown Date"; const [yearStr, monthStr] = key.split("-"); @@ -44,6 +63,27 @@ function groupImages(images: ImageRecord[]): TimelineGroup[] { .map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs })); } +function buildScrubberYears(groups: TimelineGroup[]): ScrubberYear[] { + const byYear = new Map(); + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + if (group.key === "unknown") continue; + const year = group.key.substring(0, 4); + const monthNum = Number(group.key.substring(5, 7)); + if (!byYear.has(year)) { + byYear.set(year, { year, firstGroupIndex: i, months: [] }); + } + byYear.get(year)!.months.push({ + monthNum, + label: MONTH_SHORT[monthNum - 1] ?? "", + groupIndex: i, + }); + } + // Keep insertion order so the scrubber runs the same direction as the scrolled + // content (oldest at top with taken_asc), keeping the active highlight aligned. + return Array.from(byYear.values()); +} + export function Timeline() { const images = useGalleryStore((s) => s.images); const loadMoreImages = useGalleryStore((s) => s.loadMoreImages); @@ -55,13 +95,15 @@ export function Timeline() { const parentRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); + const [activeGroupIndex, setActiveGroupIndex] = useState(0); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord; } | null>(null); - // Measure container width before first paint to avoid a single-column flash. + // parentRef is the scroll container. Its clientWidth already excludes the + // scrubber because they are flex siblings, so no further adjustment is needed. useLayoutEffect(() => { const el = parentRef.current; if (!el) return; @@ -74,35 +116,59 @@ export function Timeline() { }, []); const tileSize = tileSizeForZoom(zoomPreset); + const groups = useMemo(() => groupImages(images), [images]); + const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]); + // Show as soon as there's more than one month to jump between — not gated on + // a full year. With taken_asc sort the loaded set is oldest-first, so this + // reflects whatever range is currently loaded. + const showScrubber = groups.length > 1; + const cols = useMemo( () => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))), [containerWidth, tileSize], ); - const groups = useMemo(() => groupImages(images), [images]); + // Flatten the month groups into a single list of fixed-height rows — one + // header row per group, then one tile-row per `cols` images. This lets the + // virtualizer render only the on-screen rows, exactly like the Gallery. + // Previously each *group* was one virtual item that rendered ALL of its + // images, so scrolling into a busy month mounted thousands of tiles at once. + const { rows, rowToGroupIndex, groupFirstRow } = useMemo(() => { + const rows: TimelineRow[] = []; + const rowToGroupIndex: number[] = []; + const groupFirstRow: number[] = []; + groups.forEach((group, groupIndex) => { + groupFirstRow[groupIndex] = rows.length; + rows.push({ type: "header", group }); + rowToGroupIndex.push(groupIndex); + for (let i = 0; i < group.images.length; i += cols) { + rows.push({ type: "tiles", images: group.images.slice(i, i + cols) }); + rowToGroupIndex.push(groupIndex); + } + }); + return { rows, rowToGroupIndex, groupFirstRow }; + }, [groups, cols]); - // estimateSize must be exact so virtualizer positions groups correctly. - // Each group height = header + rowCount * (tileSize + GAP) where the last row's - // GAP acts as spacing between this group and the next header. const estimateSize = useCallback( - (index: number): number => { - const group = groups[index]; - if (!group) return HEADER_HEIGHT; - const rowCount = Math.ceil(group.images.length / cols); - return HEADER_HEIGHT + rowCount * (tileSize + GAP); - }, - [groups, cols, tileSize], + (index: number): number => + rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP, + [rows, tileSize], ); const virtualizer = useVirtualizer({ - count: groups.length, + count: rows.length, getScrollElement: () => parentRef.current, estimateSize, - overscan: 2, + overscan: 6, + paddingStart: GAP, }); - // Re-measure all items when cols changes so virtualizer positions stay accurate - // after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own). + // Refs so the scroll handler can read the latest mappings without re-binding. + const rowToGroupIndexRef = useRef(rowToGroupIndex); + rowToGroupIndexRef.current = rowToGroupIndex; + const groupFirstRowRef = useRef(groupFirstRow); + groupFirstRowRef.current = groupFirstRow; + useEffect(() => { virtualizer.measure(); }, [cols, virtualizer]); @@ -110,12 +176,25 @@ export function Timeline() { const handleScroll = useCallback(() => { const el = parentRef.current; if (!el) return; - if (el.scrollTop < 24) return; - const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; + + // Active month = the group owning the first row still visible at the top. + const scrollTop = el.scrollTop; + const items = virtualizer.getVirtualItems(); + let activeRow = items.length > 0 ? items[0].index : 0; + for (const item of items) { + if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) { + activeRow = item.index; + break; + } + } + setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0); + + if (scrollTop < 24) return; + const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600; if (nearBottom && !loadingImages && images.length < totalImages) { void loadMoreImages(); } - }, [images.length, loadMoreImages, loadingImages, totalImages]); + }, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]); useEffect(() => { const el = parentRef.current; @@ -140,105 +219,135 @@ export function Timeline() { }; }, []); - return ( -
- {images.length === 0 && loadingImages ? ( -
-
-
-

Loading timeline

-

Fetching results

-
-
- ) : images.length === 0 ? ( -
-
- - - -

- {imageLoadError ? "Could not load timeline" : "No media found"} -

-

- {imageLoadError ?? "Add a folder to see your timeline"} -

-
-
- ) : ( -
- {virtualizer.getVirtualItems().map((virtualItem) => { - const group = groups[virtualItem.index]; - if (!group) return null; - return ( -
- {/* Group header */} -
- - {group.label} - - - {group.images.length} - -
-
+ const scrollToGroup = useCallback( + (groupIndex: number) => { + const row = groupFirstRowRef.current[groupIndex] ?? 0; + virtualizer.scrollToIndex(row, { align: "start" }); + }, + [virtualizer], + ); - {/* Image grid — paddingBottom:GAP gives the gap below the last row, - matching the row-to-row gap and making estimateSize exact. */} + return ( + // Outer flex-row: fills remaining height in
's flex-col, then + // splits horizontally between the scroll area and the scrubber. +
+ {/* Scroll container — flex-1 takes all width except the scrubber */} +
+ {images.length === 0 && loadingImages ? ( +
+
+
+

Loading timeline

+

Fetching results

+
+
+ ) : images.length === 0 ? ( +
+
+ + + +

+ {imageLoadError ? "Could not load timeline" : "No media found"} +

+

+ {imageLoadError ?? "Add a folder to see your timeline"} +

+
+
+ ) : ( +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const row = rows[virtualItem.index]; + if (!row) return null; + return (
- {group.images.map((image) => ( - openImage(image)} - onContextMenu={(event) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY, image }); + {row.type === "header" ? ( +
+ + {row.group.label} + + + {row.group.images.length} + +
+
+ ) : ( +
- ))} + > + {row.images.map((image) => ( + openImage(image)} + onContextMenu={(event) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, image }); + }} + /> + ))} +
+ )}
-
- ); - })} -
- )} + ); + })} +
+ )} - {images.length > 0 && loadingImages ? ( -
-
+ {images.length > 0 && loadingImages ? ( +
+
+
+ ) : null} +
+ + {/* Scrubber — flex sibling so it stays visible while the left panel scrolls */} + {showScrubber ? ( +
+ {scrubberYears.map((yearEntry) => ( + + ))}
) : null} @@ -253,3 +362,51 @@ export function Timeline() {
); } + +interface ScrubberYearBlockProps { + yearEntry: ScrubberYear; + activeGroupIndex: number; + onScrollTo: (index: number) => void; +} + +function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: ScrubberYearBlockProps) { + const isYearActive = yearEntry.months.some((m) => m.groupIndex === activeGroupIndex); + + return ( +
+ + +
+ {Array.from({ length: 12 }, (_, i) => { + const monthNum = i + 1; + const monthEntry = yearEntry.months.find((m) => m.monthNum === monthNum); + const isActive = monthEntry !== undefined && monthEntry.groupIndex === activeGroupIndex; + if (!monthEntry) { + return ; + } + return ( +
+
+ ); +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index a08499a..cfd7e5a 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -24,7 +24,7 @@ function RestoreIcon() { return ( - + ); } @@ -88,7 +88,7 @@ export function TitleBar() { aria-label={`Update available — click to update to Phokus v${updateVersion}`} className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12" > - + {/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index c276410..56e021e 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -55,8 +55,8 @@ function SortDropdown({ onClick={() => setOpen((v) => !v)} className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${ open - ? "border-white/15 bg-white/8 text-white" - : "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200" + ? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white" + : "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white" }`} > {current?.label ?? "Sort"} @@ -68,14 +68,14 @@ function SortDropdown({ {open ? ( -
+
{options.map((option) => (
diff --git a/src/components/UpdateToast.tsx b/src/components/UpdateToast.tsx index ad1bc0a..e569768 100644 --- a/src/components/UpdateToast.tsx +++ b/src/components/UpdateToast.tsx @@ -31,7 +31,7 @@ export function UpdateToast() {

) : null} {!taggerModelPreparing && taggerModelError ? ( -

+

Download failed: {taggerModelError}

) : null} @@ -92,11 +92,11 @@ export function StepAiFeatures() {

Semantic search & similarity — built in

-
+

Search by meaning, find look-alikes

- Powers /s search, + Powers /s search, "find similar", and the Explore view, so it's part of the standard pipeline: the CLIP model (~580 MB) downloads automatically the first time embeddings run. Nothing to do — you'll see it in the background-tasks bar. diff --git a/src/components/onboarding/StepGalleryPreview.tsx b/src/components/onboarding/StepGalleryPreview.tsx index 292bdbd..44faf5f 100644 --- a/src/components/onboarding/StepGalleryPreview.tsx +++ b/src/components/onboarding/StepGalleryPreview.tsx @@ -37,14 +37,14 @@ export function StepGalleryPreview() { carry your favorites, star ratings, and video durations; hover for filename and quick actions.

-
+
{TILE_PROPS.map((props, i) => ( ))}
-
+

Click any tile to open the lightbox — keyboard navigation, zoom, inline tag editing, ratings, and a full video player. diff --git a/src/components/onboarding/StepPipeline.tsx b/src/components/onboarding/StepPipeline.tsx index c6c3550..5e47043 100644 --- a/src/components/onboarding/StepPipeline.tsx +++ b/src/components/onboarding/StepPipeline.tsx @@ -49,7 +49,7 @@ export function StepPipeline() {

{/* Fake BackgroundTasks slim bar */} -
+
{!finished ? ( @@ -57,7 +57,7 @@ export function StepPipeline() { ) : null} - Holiday Photos + Holiday Photos
{STAGES.map((stage, i) => (
-
+

It's all interruptible. Close the app whenever you like — the queue picks up where it left off next launch. diff --git a/src/components/onboarding/StepSearchDemo.tsx b/src/components/onboarding/StepSearchDemo.tsx index be81f3e..5a318ef 100644 --- a/src/components/onboarding/StepSearchDemo.tsx +++ b/src/components/onboarding/StepSearchDemo.tsx @@ -64,12 +64,12 @@ export function StepSearchDemo() { return (

- One search bar, three modes — picked by prefix. No prefix searches filenames, /s searches - by meaning, /t searches tags. + One search bar, three modes — picked by prefix. No prefix searches filenames, /s searches + by meaning, /t searches tags.

{/* Mock search bar */} -
+
@@ -77,7 +77,7 @@ export function StepSearchDemo() { {demo.query.slice(0, typed)} {!finished ? | : null} - + {demo.mode}
@@ -90,7 +90,7 @@ export function StepSearchDemo() { demo's visible state. */}
{demo.results.map((src, i) => (
diff --git a/src/components/onboarding/StepUpdates.tsx b/src/components/onboarding/StepUpdates.tsx index 5442580..fa12b73 100644 --- a/src/components/onboarding/StepUpdates.tsx +++ b/src/components/onboarding/StepUpdates.tsx @@ -24,9 +24,9 @@ export function StepUpdates() {

{/* Mini app-window mockup: the lit mark shown in a real title bar. */} -
-
-
+
+
+
@@ -48,16 +48,16 @@ export function StepUpdates() { {/* Ghosted window body, just enough to read as the app. */}
-
-
-
-
+
+
+
+
-
+
{Array.from({ length: 5 }).map((_, i) => ( -
+
))}
diff --git a/src/components/onboarding/StepViews.tsx b/src/components/onboarding/StepViews.tsx index 32f633d..76c2ad4 100644 --- a/src/components/onboarding/StepViews.tsx +++ b/src/components/onboarding/StepViews.tsx @@ -22,7 +22,7 @@ function ExplorePreview() { { size: 16, x: 70, y: 44, i: 6 }, ]; return ( -
+
{blobs.map((blob, idx) => (
+
{[2024, 2023].map((year, row) => (
{year} @@ -51,7 +51,7 @@ function TimelinePreview() { function DuplicatesPreview() { return ( -
+
@@ -69,7 +69,7 @@ export function StepViews() {

Beyond the main grid, the sidebar switches between three more ways to look at your library:

-
+
s.ffmpegStatus); const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress); @@ -10,7 +56,7 @@ export function FfmpegStatusRow() { if (ffmpegStatus === "installed") { return (
- +
@@ -27,14 +73,14 @@ export function FfmpegStatusRow() {

Media engine download failed

-

{ffmpegError}

+

{ffmpegError}

You can keep going — photos work without it. Video thumbnails and metadata will start automatically once the download succeeds.

+ ); + })} +
+

First-time setup

-
+
diff --git a/src/components/onboarding/fakes.tsx b/src/components/onboarding/fakes.tsx index 873ed62..19e569d 100644 --- a/src/components/onboarding/fakes.tsx +++ b/src/components/onboarding/fakes.tsx @@ -82,7 +82,7 @@ export function FakeTile({ className?: string; }) { return ( -
+
{loaded ? ( ) : ( @@ -116,16 +116,16 @@ export function FakeTile({ export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) { const className = state === "active" - ? "bg-white/5 text-gray-300" + ? "bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300" : state === "done" - ? "bg-emerald-500/10 text-emerald-400" - : "bg-white/4 text-gray-600"; + ? "bg-emerald-500/10 text-emerald-400 light-theme:bg-emerald-100 light-theme:text-emerald-700" + : "bg-gray-900/60 text-gray-600 light-theme:bg-gray-800 light-theme:text-gray-500"; return {label}; } export function FakeProgressBar({ fraction, className = "" }: { fraction: number | null; className?: string }) { return ( -
+
{fraction === null ? (
) : ( @@ -143,7 +143,7 @@ export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => voi