From 9047c8053ace9e803a38d46ce3dee248fb4d77f3 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Wed, 17 Jun 2026 18:18:35 +0100 Subject: [PATCH] =?UTF-8?q?feat:=200.1.1=20=E2=80=94=20timeline=20scrubber?= =?UTF-8?q?,=20gallery=20virtualisation,=20folder=20reorder,=20QoL=20polis?= =?UTF-8?q?h?= 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() {