From 9047c8053ace9e803a38d46ce3dee248fb4d77f3 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Wed, 17 Jun 2026 18:18:35 +0100 Subject: [PATCH 01/11] =?UTF-8?q?feat:=200.1.1=20=E2=80=94=20timeline=20sc?= =?UTF-8?q?rubber,=20gallery=20virtualisation,=20folder=20reorder,=20QoL?= =?UTF-8?q?=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timeline: - Add a right-edge scrubber (year labels + month dots) that jumps to any period; runs in the same direction as the scrolled content - Load the full filtered set in Timeline view so the scrubber spans the whole library instead of just the first page Gallery & UI: - Virtualise the gallery grid - Folder reordering in the sidebar (drag + persisted sort_order) and a themed dropdown component - Subtle Light theme contrast fixes for toggles, secondary buttons and the update toast Embedding workers now defer video jobs without a thumbnail at claim time and requeue any previously-failed deferred jobs on startup, so videos no longer churn through failed embeddings. QoL polish across BackgroundTasks, DuplicateFinder, Lightbox and VideoPlayer. --- README.md | 2 +- src-tauri/src/commands.rs | 18 +- src-tauri/src/db.rs | 63 +++++- src-tauri/src/embedder.rs | 4 +- src-tauri/src/indexer.rs | 12 +- src-tauri/src/lib.rs | 6 + src/components/BackgroundTasks.tsx | 13 +- src/components/DuplicateFinder.tsx | 2 +- src/components/Gallery.tsx | 122 +++++++---- src/components/Lightbox.tsx | 16 +- src/components/SettingsModal.tsx | 64 ++++-- src/components/Sidebar.tsx | 197 ++++++++++++++++- src/components/ThemedDropdown.tsx | 106 +++++++++ src/components/Timeline.tsx | 335 ++++++++++++++++++++--------- src/components/UpdateToast.tsx | 2 +- src/components/VideoPlayer.tsx | 2 +- src/index.css | 52 ++++- src/store.ts | 48 ++++- 18 files changed, 868 insertions(+), 196 deletions(-) create mode 100644 src/components/ThemedDropdown.tsx 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/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 77cd5ab..1a48de9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -267,6 +267,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>, @@ -1468,6 +1482,7 @@ fn kmeans_cosine( pub struct FailedEmbeddingItem { pub image_id: i64, pub filename: String, + pub path: String, pub error: Option, } @@ -1480,9 +1495,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..0f6a9e8 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,8 @@ 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 +332,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 +818,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 +1229,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 +1517,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 +1529,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", @@ -1876,12 +1924,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 +1940,8 @@ 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::>>()?) 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..8cddc55 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, diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index e0cd20a..b455572 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 = { @@ -33,6 +34,7 @@ interface Task { interface FailedEmbeddingItem { image_id: number; filename: string; + path: string; error: string | null; } @@ -504,12 +506,21 @@ export function BackgroundTasks() { -
+

{item.filename}

{item.error && (

{item.error}

)}
+
))} diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index ce9c344..ba07724 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -165,7 +165,7 @@ export function DuplicateFinder() { : null; return ( -
+
{/* Header */}
diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index bbfefac..12d4566 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useCallback, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { convertFileSrc } from "@tauri-apps/api/core"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; @@ -119,11 +120,7 @@ export function ImageTile({ const similarScope = useGalleryStore((state) => state.similarScope); const canFindSimilar = image.embedding_status === "ready"; - const src = image.thumbnail_path - ? convertFileSrc(image.thumbnail_path) - : image.media_kind === "image" && image.path - ? convertFileSrc(image.path) - : null; + const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null; return ( +

{selectedImage.path}

diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index b2779b9..5fb5561 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-black/[0.06] light-theme:hover:text-gray-900" }`} 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-black/[0.06] light-theme:hover:text-gray-900" }`} 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,103 @@ 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); + + 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); + void reorderFolders(next.map((folder) => folder.id)); + }; const handleAddFolder = async () => { const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" }); @@ -387,28 +541,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/ThemedDropdown.tsx b/src/components/ThemedDropdown.tsx new file mode 100644 index 0000000..d33a79b --- /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..e21d433 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,18 @@ interface TimelineGroup { 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 +58,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 +90,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,16 +111,18 @@ 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]); - - // 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]; @@ -101,8 +140,11 @@ export function Timeline() { overscan: 2, }); - // 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). + const groupsRef = useRef(groups); + groupsRef.current = groups; + const estimateSizeRef = useRef(estimateSize); + estimateSizeRef.current = estimateSize; + useEffect(() => { virtualizer.measure(); }, [cols, virtualizer]); @@ -110,6 +152,23 @@ export function Timeline() { const handleScroll = useCallback(() => { const el = parentRef.current; if (!el) return; + + const scrollTop = el.scrollTop; + const g = groupsRef.current; + const es = estimateSizeRef.current; + let cumHeight = 0; + let newActive = 0; + for (let i = 0; i < g.length; i++) { + const h = es(i); + if (cumHeight + h > scrollTop + HEADER_HEIGHT / 2) { + newActive = i; + break; + } + cumHeight += h; + newActive = i; + } + setActiveGroupIndex(newActive); + if (el.scrollTop < 24) return; const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; if (nearBottom && !loadingImages && images.length < totalImages) { @@ -140,105 +199,131 @@ 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( + (index: number) => { + virtualizer.scrollToIndex(index, { 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 group = groups[virtualItem.index]; + if (!group) return null; + return (
- {group.images.map((image) => ( - openImage(image)} - onContextMenu={(event) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY, image }); - }} - /> - ))} -
-
- ); - })} -
- )} +
+ + {group.label} + + + {group.images.length} + +
+
- {images.length > 0 && loadingImages ? ( -
-
+
+ {group.images.map((image) => ( + openImage(image)} + onContextMenu={(event) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, image }); + }} + /> + ))} +
+
+ ); + })} +
+ )} + + {images.length > 0 && loadingImages ? ( +
+
+
+ ) : null} +
+ + {/* Scrubber — flex sibling so it stays visible while the left panel scrolls */} + {showScrubber ? ( +
+ {scrubberYears.map((yearEntry) => ( + + ))}
) : null} @@ -253,3 +338,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/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() {

{open ? ( -
+
) : ( {open ? ( -
+
{options.map((option) => (
) : 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 +
+ ); +} + 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); @@ -58,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; @@ -304,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 && ( @@ -357,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) */} @@ -416,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" @@ -428,7 +485,7 @@ export function BackgroundTasks() { )} {stage.label} - + {stage.detail} {workerKey && ( @@ -467,15 +524,25 @@ export function BackgroundTasks() {
{taskHasFailed && ( - +
+ {task.hasFailedTagging ? ( + + ) : null} + +
)} {task.id >= 0 && ( @@ -497,31 +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/TitleBar.tsx b/src/components/TitleBar.tsx index a08499a..f82f2af 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -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 573da34..56e021e 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -158,6 +158,8 @@ export function Toolbar() { const setMinimumRating = useGalleryStore((state) => state.setMinimumRating); const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly); const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly); + const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly); + const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly); const similarScope = useGalleryStore((state) => state.similarScope); const setSimilarScope = useGalleryStore((state) => state.setSimilarScope); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); @@ -166,6 +168,7 @@ export function Toolbar() { const activeView = useGalleryStore((state) => state.activeView); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); + const hasAnyFailedTagging = Object.values(mediaJobProgress).some((p) => p.tagging_failed > 0); const [searchCommand, setSearchCommand] = useState(null); const [searchQuery, setSearchQuery] = useState(search); @@ -441,12 +444,12 @@ export function Toolbar() { {/* Filter row */}
- { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} /> - { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> - { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> - { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} /> - { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} /> - { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} /> + { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> setSimilarScope("current_folder")} /> setSimilarScope("all_media")} /> {hasAnyFailedEmbeddings ? ( @@ -457,6 +460,14 @@ export function Toolbar() { onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)} /> ) : null} + {hasAnyFailedTagging ? ( + setFailedTaggingOnly(!failedTaggingOnly)} + /> + ) : null} {isSimilarResults ? Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"} : null}
diff --git a/src/store.ts b/src/store.ts index 4d79b4a..05cbe35 100644 --- a/src/store.ts +++ b/src/store.ts @@ -309,6 +309,7 @@ interface GalleryState { favoritesOnly: boolean; minimumRating: number; failedEmbeddingsOnly: boolean; + failedTaggingOnly: boolean; zoomPreset: ZoomPreset; selectedImage: ImageRecord | null; collectionTitle: string | null; @@ -403,6 +404,8 @@ interface GalleryState { setFavoritesOnly: (favoritesOnly: boolean) => void; setMinimumRating: (minimumRating: number) => void; setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void; + setFailedTaggingOnly: (failedTaggingOnly: boolean) => void; + showFailedTagging: (folderId: number) => void; setZoomPreset: (zoomPreset: ZoomPreset) => void; openImage: (image: ImageRecord) => void; closeImage: () => void; @@ -593,6 +596,7 @@ function matchesFilters( favoritesOnly: boolean, minimumRating: number, failedEmbeddingsOnly: boolean, + failedTaggingOnly: boolean, search: string, ): boolean { const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId; @@ -600,7 +604,8 @@ function matchesFilters( const matchesFavorite = !favoritesOnly || image.favorite; const matchesRating = image.rating >= minimumRating; const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed"; - return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesSearch(image, search); + const matchesFailedTagging = !failedTaggingOnly || image.ai_tagger_error !== null; + return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesFailedTagging && matchesSearch(image, search); } function compareNullableNumber(a: number | null, b: number | null): number { @@ -710,6 +715,7 @@ export const useGalleryStore = create((set, get) => ({ favoritesOnly: false, minimumRating: 0, failedEmbeddingsOnly: false, + failedTaggingOnly: false, zoomPreset: "comfortable", selectedImage: null, collectionTitle: null, @@ -879,7 +885,7 @@ export const useGalleryStore = create((set, get) => ({ }, selectFolder: (folderId) => { - set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null }); + set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null }); void get().loadImages(true); }, @@ -912,7 +918,7 @@ export const useGalleryStore = create((set, get) => ({ }, loadImages: async (reset = false) => { - const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, activeView } = get(); + const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, activeView } = get(); const parsedSearch = parseSearchValue(search); const requestToken = ++galleryRequestToken; set({ loadingImages: true, imageLoadError: null }); @@ -999,6 +1005,7 @@ export const useGalleryStore = create((set, get) => ({ favorites_only: favoritesOnly, rating_min: minimumRating > 0 ? minimumRating : null, embedding_failed_only: failedEmbeddingsOnly, + tagging_failed_only: failedTaggingOnly, sort, offset, limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE, @@ -1108,7 +1115,35 @@ export const useGalleryStore = create((set, get) => ({ }, setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => { - set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + set({ failedEmbeddingsOnly, failedTaggingOnly: failedEmbeddingsOnly ? false : get().failedTaggingOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + }, + + setFailedTaggingOnly: (failedTaggingOnly) => { + set({ failedTaggingOnly, failedEmbeddingsOnly: failedTaggingOnly ? false : get().failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + }, + + showFailedTagging: (folderId) => { + set({ + selectedFolderId: folderId, + activeView: "gallery", + search: "", + mediaFilter: "all", + favoritesOnly: false, + minimumRating: 0, + failedEmbeddingsOnly: false, + failedTaggingOnly: true, + images: [], + loadedCount: 0, + collectionTitle: null, + similarSourceImageId: null, + similarSourceFolderId: null, + similarHasMore: false, + similarFolderId: null, + similarCrop: null, + imageLoadError: null, + }); void get().loadImages(true); }, @@ -2205,6 +2240,7 @@ export const useGalleryStore = create((set, get) => ({ state.favoritesOnly, state.minimumRating, state.failedEmbeddingsOnly, + state.failedTaggingOnly, state.search, ), ); @@ -2244,6 +2280,7 @@ export const useGalleryStore = create((set, get) => ({ state.favoritesOnly, state.minimumRating, state.failedEmbeddingsOnly, + state.failedTaggingOnly, state.search, ), ); From a4c928345cc42da4b0dc460a254240cda26e4e05 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Thu, 18 Jun 2026 00:49:30 +0100 Subject: [PATCH 04/11] chore: add changelog helper and unreleased notes --- CHANGELOG.md | 20 ++++++++++++ package.json | 1 + tools/changelog-add.mjs | 71 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 tools/changelog-add.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d8669..dcb24a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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 + +- 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 + +- 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. + ## [0.1.0] — 2026-06-14 First public release. Windows desktop, distributed as an unsigned NSIS 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/tools/changelog-add.mjs b/tools/changelog-add.mjs new file mode 100644 index 0000000..cb24c92 --- /dev/null +++ b/tools/changelog-add.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const SECTION_BY_TYPE = { + added: "Added", + changed: "Changed", + deprecated: "Deprecated", + removed: "Removed", + fixed: "Fixed", + security: "Security", +}; + +function usage() { + console.error([ + "Usage:", + " pnpm changelog:add -- --type fixed --message \"Fix thing\"", + "", + `Types: ${Object.keys(SECTION_BY_TYPE).join(", ")}`, + ].join("\n")); + process.exit(1); +} + +function argValue(name) { + const index = process.argv.indexOf(`--${name}`); + if (index === -1) return null; + return process.argv[index + 1] ?? null; +} + +const type = argValue("type")?.toLowerCase(); +const message = argValue("message")?.trim(); + +if (!type || !message || !SECTION_BY_TYPE[type]) { + usage(); +} + +const section = SECTION_BY_TYPE[type]; +const changelogPath = resolve("CHANGELOG.md"); +let changelog = readFileSync(changelogPath, "utf8"); + +if (!/^## \[Unreleased\]/m.test(changelog)) { + changelog = changelog.replace( + /(\r?\n)(## \[[^\r\n]+\])/, + `$1## [Unreleased]$1$1$2`, + ); +} + +const unreleasedMatch = changelog.match(/^## \[Unreleased\][\s\S]*?(?=^## \[|\z)/m); +if (!unreleasedMatch) { + throw new Error("Could not find or create an Unreleased section."); +} + +const unreleased = unreleasedMatch[0]; +const lineEnding = changelog.includes("\r\n") ? "\r\n" : "\n"; +const bullet = `- ${message}`; + +let nextUnreleased; +const sectionRegex = new RegExp(`(^### ${section}\\s*)([\\s\\S]*?)(?=^### |\\z)`, "m"); +const sectionMatch = unreleased.match(sectionRegex); + +if (sectionMatch) { + const body = sectionMatch[2].trimEnd(); + const replacement = `${sectionMatch[1]}${body ? `${body}${lineEnding}` : ""}${bullet}${lineEnding}${lineEnding}`; + nextUnreleased = unreleased.replace(sectionRegex, replacement); +} else { + const insert = `${lineEnding}### ${section}${lineEnding}${lineEnding}${bullet}${lineEnding}`; + nextUnreleased = unreleased.trimEnd() + insert + lineEnding; +} + +changelog = changelog.replace(unreleased, nextUnreleased); +writeFileSync(changelogPath, changelog); From 1df75fd49086cd73bd01bb3c01f2d876b9318729 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 20 Jun 2026 18:08:39 +0100 Subject: [PATCH 05/11] Refine explore and settings theming --- src/components/FolderScopeDropdown.tsx | 10 +- src/components/TagCloud.tsx | 93 ++++++---- src/index.css | 230 +++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 40 deletions(-) diff --git a/src/components/FolderScopeDropdown.tsx b/src/components/FolderScopeDropdown.tsx index c98c261..8657adb 100644 --- a/src/components/FolderScopeDropdown.tsx +++ b/src/components/FolderScopeDropdown.tsx @@ -33,10 +33,10 @@ export function FolderScopeDropdown() { }; return ( -
+
{open ? ( -
+
{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/index.css b/src/index.css index dceb117..c687b5d 100644 --- a/src/index.css +++ b/src/index.css @@ -61,6 +61,236 @@ html[data-theme="subtle-light"] .media-dark-surface { --color-gray-100: #f3f4f6; } +html[data-theme="subtle-light"] .settings-modal { + background: #f4f2ea !important; + border-color: #d6d1c7 !important; + color: #1f2937 !important; + box-shadow: 0 24px 80px rgb(0 0 0 / 0.24) !important; +} + +html[data-theme="subtle-light"] .settings-modal aside { + background: #ece9e1 !important; + border-color: #d6d1c7 !important; +} + +html[data-theme="subtle-light"] .settings-modal aside > div:first-child { + border-color: #d6d1c7 !important; +} + +html[data-theme="subtle-light"] .settings-modal .settings-nav-active { + background: #d8d4ca !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .settings-modal .settings-nav-inactive { + background: transparent !important; + color: #4b5563 !important; +} + +html[data-theme="subtle-light"] .settings-modal .settings-nav-inactive:hover { + background: #e2ded5 !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .settings-modal .settings-model-card, +html[data-theme="subtle-light"] .settings-modal .settings-light-panel { + background: #fbfaf6 !important; + border-color: #d6d1c7 !important; + color: #1f2937 !important; +} + +html[data-theme="subtle-light"] .settings-modal button:not([role="switch"]) { + background: #fbfaf6 !important; + border-color: #cfc8ba !important; + color: #374151 !important; +} + +html[data-theme="subtle-light"] .settings-modal button:not([role="switch"]):hover { + background: #eeeae0 !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .settings-modal input { + background: #ffffff !important; + border-color: #cfc8ba !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .settings-modal .text-white, +html[data-theme="subtle-light"] .settings-modal .text-gray-200, +html[data-theme="subtle-light"] .settings-modal .text-gray-300, +html[data-theme="subtle-light"] .settings-modal .text-gray-400 { + color: #111827 !important; +} + +html[data-theme="subtle-light"] .settings-modal .text-gray-500, +html[data-theme="subtle-light"] .settings-modal .text-gray-600 { + color: #5f5a52 !important; +} + +html[data-theme="subtle-light"] .settings-modal .text-emerald-200, +html[data-theme="subtle-light"] .settings-modal .text-emerald-300 { + color: #047857 !important; +} + +html[data-theme="subtle-light"] .settings-modal .text-sky-300 { + color: #0369a1 !important; +} + +html[data-theme="subtle-light"] .settings-modal .text-amber-200, +html[data-theme="subtle-light"] .settings-modal .text-amber-300 { + color: #b45309 !important; +} + +html[data-theme="subtle-light"] .settings-modal .text-red-200 { + color: #b91c1c !important; +} + +html[data-theme="subtle-light"] .settings-modal .bg-white\/10, +html[data-theme="subtle-light"] .settings-modal .bg-white\/5, +html[data-theme="subtle-light"] .settings-modal .bg-white\/\[0\.025\], +html[data-theme="subtle-light"] .settings-modal .bg-white\/\[0\.04\], +html[data-theme="subtle-light"] .settings-modal .bg-white\/\[0\.045\], +html[data-theme="subtle-light"] .settings-modal .bg-white\/\[0\.055\] { + background: #fbfaf6 !important; +} + +html[data-theme="subtle-light"] .settings-modal .border-white\/10, +html[data-theme="subtle-light"] .settings-modal .border-white\/\[0\.05\], +html[data-theme="subtle-light"] .settings-modal .border-white\/\[0\.06\], +html[data-theme="subtle-light"] .settings-modal .border-white\/\[0\.07\] { + border-color: #d6d1c7 !important; +} + +html[data-theme="subtle-light"] .explore-view { + background: + radial-gradient(ellipse at top, rgb(59 130 246 / 0.08), transparent 48%), + radial-gradient(ellipse at 80% 75%, rgb(16 185 129 / 0.07), transparent 42%), + #f4f2ea !important; + color: #1f2937 !important; +} + +html[data-theme="subtle-light"] .explore-header { + background: #f4f2ea !important; + border-color: #d8d2c7 !important; +} + +html[data-theme="subtle-light"] .explore-title { + color: #111827 !important; +} + +html[data-theme="subtle-light"] .explore-subtitle, +html[data-theme="subtle-light"] .explore-empty { + color: #7a746b !important; +} + +html[data-theme="subtle-light"] .explore-mode-toggle { + background: #ece8dd !important; + border-color: #d0c8ba !important; +} + +html[data-theme="subtle-light"] .explore-mode-button { + background: transparent !important; + color: #4b5563 !important; +} + +html[data-theme="subtle-light"] .explore-mode-button:hover { + background: #e0dbcf !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .explore-mode-button.bg-white\/10 { + background: #d8d4ca !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .explore-cluster-grid { + background-image: radial-gradient(circle, rgb(31 41 55 / 0.18) 1px, transparent 1px) !important; + opacity: 0.16 !important; +} + +html[data-theme="subtle-light"] .explore-cluster-card { + background: #fbfaf6 !important; + border-color: #d8d2c7 !important; + box-shadow: 0 14px 36px rgb(28 25 23 / 0.18) !important; +} + +html[data-theme="subtle-light"] .explore-cluster-card:hover { + box-shadow: 0 18px 44px rgb(28 25 23 / 0.22) !important; +} + +html[data-theme="subtle-light"] .explore-cluster-overlay { + background: linear-gradient( + to top, + rgb(251 250 246 / 0.9), + rgb(251 250 246 / 0.52) 34%, + rgb(251 250 246 / 0.06) 68%, + transparent + ) !important; +} + +html[data-theme="subtle-light"] .explore-cluster-label { + color: #6b6257 !important; +} + +html[data-theme="subtle-light"] .explore-cluster-count { + color: #111827 !important; +} + +html[data-theme="subtle-light"] .explore-tag-word:hover { + background: #e8e2d6 !important; +} + +html[data-theme="subtle-light"] .explore-tag-word span:first-child { + color: #374151 !important; +} + +html[data-theme="subtle-light"] .explore-spinner { + border-color: rgb(17 24 39 / 0.18) !important; + border-top-color: rgb(17 24 39 / 0.55) !important; +} + +html[data-theme="subtle-light"] .feature-scope-trigger { + background: #f8f6ef !important; + border-color: #d0c8ba !important; + color: #4b5563 !important; +} + +html[data-theme="subtle-light"] .feature-scope-trigger:hover, +html[data-theme="subtle-light"] .feature-scope-dropdown:has(.feature-scope-menu) .feature-scope-trigger { + background: #ece8dd !important; + border-color: #bfb6a7 !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .feature-scope-menu { + background: #fbfaf6 !important; + border-color: #d0c8ba !important; + color: #1f2937 !important; + box-shadow: 0 18px 44px rgb(28 25 23 / 0.2) !important; +} + +html[data-theme="subtle-light"] .feature-scope-option { + color: #4b5563 !important; +} + +html[data-theme="subtle-light"] .feature-scope-option:hover { + background: #ece8dd !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .feature-scope-option.bg-white\/6 { + background: #d8d4ca !important; + color: #111827 !important; +} + +@media (prefers-reduced-motion: reduce) { + .explore-cluster-card, + .explore-cluster-card img { + transition-duration: 0.01ms !important; + } +} + /* Custom scrollbar */ ::-webkit-scrollbar { width: 6px; From 21f6c30d25c0730c88902f70b5212719de58e79f Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 08:47:48 +0100 Subject: [PATCH 06/11] perf: row-virtualize Timeline instead of per-month Each month was a single virtual item that rendered all of its tiles, so scrolling into a busy month mounted thousands of ImageTiles at once. Flatten months into a fixed-height row list (header rows + tile rows of `cols` images), mirroring the Gallery grid, so only on-screen rows render and thumbnails stream in as you scroll. Active-month tracking and scrubber jump-to-month are remapped to the flat row model. --- src/components/Timeline.tsx | 156 +++++++++++++++++++++--------------- 1 file changed, 90 insertions(+), 66 deletions(-) diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index e21d433..a2de2eb 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -14,6 +14,11 @@ 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; @@ -123,27 +128,46 @@ export function Timeline() { [containerWidth, tileSize], ); + // 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]); + 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, }); - const groupsRef = useRef(groups); - groupsRef.current = groups; - const estimateSizeRef = useRef(estimateSize); - estimateSizeRef.current = estimateSize; + // 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(); @@ -153,28 +177,24 @@ export function Timeline() { const el = parentRef.current; if (!el) return; + // Active month = the group owning the first row still visible at the top. const scrollTop = el.scrollTop; - const g = groupsRef.current; - const es = estimateSizeRef.current; - let cumHeight = 0; - let newActive = 0; - for (let i = 0; i < g.length; i++) { - const h = es(i); - if (cumHeight + h > scrollTop + HEADER_HEIGHT / 2) { - newActive = i; + 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; } - cumHeight += h; - newActive = i; } - setActiveGroupIndex(newActive); + setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0); - if (el.scrollTop < 24) return; - const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; + 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; @@ -200,8 +220,9 @@ export function Timeline() { }, []); const scrollToGroup = useCallback( - (index: number) => { - virtualizer.scrollToIndex(index, { align: "start" }); + (groupIndex: number) => { + const row = groupFirstRowRef.current[groupIndex] ?? 0; + virtualizer.scrollToIndex(row, { align: "start" }); }, [virtualizer], ); @@ -250,8 +271,8 @@ export function Timeline() { ) : (

{virtualizer.getVirtualItems().map((virtualItem) => { - const group = groups[virtualItem.index]; - if (!group) return null; + const row = rows[virtualItem.index]; + if (!row) return null; return (
-
- - {group.label} - - - {group.images.length} - -
-
- -
- {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 }); + }} + /> + ))} +
+ )}
); })} From 479de76ebbad46eebb6bd68a584382dcc9257306 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 08:48:05 +0100 Subject: [PATCH 07/11] perf: stop full re-sort on media-updated batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replaceExistingImages re-sorted the entire loaded image array on every media-updated event. Harmless for the ~200-item gallery window, but in Timeline (which loads the whole library) it was an O(n log n) pass many times per second during background indexing — severe lag, occasional crashes. Thumbnail/metadata fills don't change list position (Timeline re-buckets by taken_at separately), so replace records in place and skip the sort; return the same array reference when nothing matched to avoid a wasted re-render. --- src/store.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/store.ts b/src/store.ts index 05cbe35..2daca96 100644 --- a/src/store.ts +++ b/src/store.ts @@ -682,11 +682,24 @@ function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: So function replaceExistingImages( currentImages: ImageRecord[], updatedImages: ImageRecord[], - sort: SortOrder, ): ImageRecord[] { + // Replace matched records in place WITHOUT re-sorting. `media-updated` carries + // thumbnail/metadata fills that don't move an item in the list (Timeline + // re-buckets by taken_at separately), and it fires constantly while the + // background workers run. Re-sorting here meant an O(n log n) pass on every + // batch — fine for the ~200-item gallery window, but a UI-freezing churn in + // Timeline view where `images` can hold the entire library (TIMELINE_PAGE_SIZE). + // Returning the same array reference when nothing matched also avoids a wasted + // re-render. Relative order for just-updated items is corrected on next load. const updatesByPath = new Map(updatedImages.map((image) => [image.path, image])); - const nextImages = currentImages.map((image) => updatesByPath.get(image.path) ?? image); - return nextImages.sort((a, b) => compareImages(a, b, sort)); + let changed = false; + const nextImages = currentImages.map((image) => { + const update = updatesByPath.get(image.path); + if (!update) return image; + changed = true; + return update; + }); + return changed ? nextImages : currentImages; } export function tileSizeForZoom(zoomPreset: ZoomPreset): number { @@ -2295,7 +2308,7 @@ export const useGalleryStore = create((set, get) => ({ } return { - images: replaceExistingImages(state.images, visibleImages, state.sort), + images: replaceExistingImages(state.images, visibleImages), selectedImage, }; }); From b7cfc9177ed6bcdabe82fa09147fb206e67fe3b8 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 08:48:05 +0100 Subject: [PATCH 08/11] fix: subtle-light theme parity for lightbox panel and media surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In subtle-light, the lightbox dragged its whole surface (including the metadata panel) dark via media-dark-surface, while conventional-dark themed the panel normally. Scope the dark surface to the media canvas and re-light the panel by remapping --color-* variables on a .lightbox-panel wrapper (Tailwind v4 resolves every colour utility through these vars, so this re-themes the subtree — including accents — with no !important). Mark gallery/duplicate media tiles as media-dark-surface so their on-image overlays stay light-on-dark, and theme the window restore icon via var(--color-gray-950) instead of a hardcoded hex. --- src/components/DuplicateFinder.tsx | 2 +- src/components/Gallery.tsx | 2 +- src/components/Lightbox.tsx | 2 +- src/components/TitleBar.tsx | 2 +- src/index.css | 30 ++++++++++++++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index 417045b..1ab85fe 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -71,7 +71,7 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) { return (
-
+

{selectedImage.filename}

diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index f82f2af..cfd7e5a 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -24,7 +24,7 @@ function RestoreIcon() { return ( - + ); } diff --git a/src/index.css b/src/index.css index c687b5d..c3d7ee7 100644 --- a/src/index.css +++ b/src/index.css @@ -61,6 +61,36 @@ html[data-theme="subtle-light"] .media-dark-surface { --color-gray-100: #f3f4f6; } +/* The lightbox keeps its dark canvas + backdrop (via .media-dark-surface on the + root), but the metadata panel is chrome and should follow the active theme. + In subtle-light it lives inside the dark-surface root, so re-light it here by + remapping the palette on the panel itself. Tailwind v4 resolves every colour + utility through a --color-* variable, so remapping the variables re-themes the + whole subtree — including accents — without a single !important. */ +html[data-theme="subtle-light"] .lightbox-panel { + --color-white: #18202c; + --color-gray-950: #e9e7e2; + --color-gray-900: #dedbd4; + --color-gray-800: #cfcbc3; + --color-gray-700: #aea99f; + --color-gray-600: #817b72; + --color-gray-500: #655f57; + --color-gray-400: #514b44; + --color-gray-300: #403b35; + --color-gray-200: #302c28; + --color-gray-100: #24211e; + /* Pastel accents are tuned for a dark panel; darken them for the light one. */ + --color-amber-300: #b45309; + --color-amber-400: #b45309; + --color-sky-300: #0369a1; + --color-emerald-300: #047857; + --color-rose-300: #be123c; + --color-rose-400: #be123c; + --color-red-300: #b91c1c; + --color-violet-300: #6d28d9; + --color-violet-400: #6d28d9; +} + html[data-theme="subtle-light"] .settings-modal { background: #f4f2ea !important; border-color: #d6d1c7 !important; From d027de675bbb6cd5c27780e899723154a1010b89 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 08:48:18 +0100 Subject: [PATCH 09/11] fix: debounce folder keyboard-reorder persistence Holding Up/Down on a folder's drag handle fired a reorder_folders DB write per keystroke. Update the local order immediately for responsiveness, but debounce the persist (400ms) with cleanup on unmount. --- src/components/Sidebar.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4c69268..cc51fbd 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -358,6 +358,14 @@ export function Sidebar() { 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; @@ -442,7 +450,13 @@ export function Sidebar() { [next[currentIndex], next[nextIndex]] = [next[nextIndex], next[currentIndex]]; customFoldersRef.current = next; setCustomFolders(next); - void reorderFolders(next.map((folder) => folder.id)); + // 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 () => { From 779a18f56e6aef873c2a80fd36b3f05d6e8cc089 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 08:48:19 +0100 Subject: [PATCH 10/11] fix: use valid end-of-input anchor in changelog-add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS regex has no \z anchor — it matched a literal 'z', so appending to the last section under [Unreleased] silently failed and duplicated the heading. Use $(?![\s\S]) to anchor to true end-of-input. --- tools/changelog-add.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/changelog-add.mjs b/tools/changelog-add.mjs index cb24c92..242cd8f 100644 --- a/tools/changelog-add.mjs +++ b/tools/changelog-add.mjs @@ -45,7 +45,9 @@ if (!/^## \[Unreleased\]/m.test(changelog)) { ); } -const unreleasedMatch = changelog.match(/^## \[Unreleased\][\s\S]*?(?=^## \[|\z)/m); +// JS regex has no \z anchor, so match to the next version heading or true +// end-of-input ($ with nothing following — \n-tolerant under the m flag). +const unreleasedMatch = changelog.match(/^## \[Unreleased\][\s\S]*?(?=^## \[|$(?![\s\S]))/m); if (!unreleasedMatch) { throw new Error("Could not find or create an Unreleased section."); } @@ -55,7 +57,7 @@ const lineEnding = changelog.includes("\r\n") ? "\r\n" : "\n"; const bullet = `- ${message}`; let nextUnreleased; -const sectionRegex = new RegExp(`(^### ${section}\\s*)([\\s\\S]*?)(?=^### |\\z)`, "m"); +const sectionRegex = new RegExp(`(^### ${section}\\s*)([\\s\\S]*?)(?=^### |$(?![\\s\\S]))`, "m"); const sectionMatch = unreleased.match(sectionRegex); if (sectionMatch) { From 50e8bc8e4d979ed9c7c9dde16ad611bbf3545ca4 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 21 Jun 2026 08:48:20 +0100 Subject: [PATCH 11/11] docs: record 0.1.1 QoL fixes in changelog --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb24a5..1598965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### 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. @@ -19,12 +25,32 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### 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