Polish search and embedding UX

- add semantic text search with toolbar mode switching and sqlite-vec query support
- improve embedding progress visibility, failure recovery, and similar-image affordances
- add search clearing and keyboard controls for filename vs semantic search modes
- refine background task interactions and gallery/lightbox embedding states

Refs: #4
This commit is contained in:
2026-04-06 01:45:25 +01:00
parent 51e4c2c1f7
commit c6a66d1ba9
16 changed files with 978 additions and 415 deletions
+5
View File
@@ -1369,6 +1369,9 @@ name = "esaxx-rs"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
dependencies = [
"cc",
]
[[package]]
name = "event-listener"
@@ -3962,6 +3965,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-opener",
"tokenizers",
"tokio",
"uuid",
"walkdir",
@@ -5944,6 +5948,7 @@ dependencies = [
"derive_builder",
"esaxx-rs",
"getrandom 0.3.4",
"indicatif",
"itertools",
"log",
"macro_rules_attribute",
+1
View File
@@ -39,3 +39,4 @@ candle-core = { version = "0.10.2", features = ["cuda"] }
candle-nn = { version = "0.10.2", features = ["cuda"] }
candle-transformers = { version = "0.10.2", features = ["cuda"] }
hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] }
tokenizers = "0.22.1"
+59
View File
@@ -1,4 +1,5 @@
use crate::db::{self, DbPool, Folder, FolderJobProgress, ImageRecord};
use crate::embedder::ClipImageEmbedder;
use crate::indexer;
use crate::vector;
use serde::{Deserialize, Serialize};
@@ -44,6 +45,15 @@ pub struct RetryFailedEmbeddingsParams {
pub folder_id: i64,
}
#[derive(Deserialize)]
pub struct SemanticSearchParams {
pub query: String,
pub folder_id: Option<i64>,
pub media_kind: Option<String>,
pub favorites_only: Option<bool>,
pub limit: Option<usize>,
}
#[tauri::command]
pub async fn add_folder(
app: AppHandle,
@@ -188,3 +198,52 @@ pub async fn retry_failed_embeddings(
let conn = db.get().map_err(|e| e.to_string())?;
db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn semantic_search_images(
db: State<'_, DbState>,
params: SemanticSearchParams,
) -> Result<Vec<ImageRecord>, String> {
let embedder = ClipImageEmbedder::new().map_err(|e| e.to_string())?;
let embedding = embedder.embed_text(&params.query).map_err(|e| e.to_string())?;
let conn = db.get().map_err(|e| e.to_string())?;
let limit = params.limit.unwrap_or(64);
let ids = vector::search_image_ids_by_embedding(&conn, &embedding, limit).map_err(|e| e.to_string())?;
let mut images = db::get_images_by_ids(&conn, &ids).map_err(|e| e.to_string())?;
if let Some(folder_id) = params.folder_id {
images.retain(|image| image.folder_id == folder_id);
}
if let Some(media_kind) = params.media_kind.as_deref() {
images.retain(|image| image.media_kind == media_kind);
}
if params.favorites_only.unwrap_or(false) {
images.retain(|image| image.favorite);
}
Ok(images)
}
#[derive(Serialize)]
pub struct WorkerStates {
pub thumbnail_paused: bool,
pub metadata_paused: bool,
pub embedding_paused: bool,
}
#[tauri::command]
pub async fn set_worker_paused(worker: String, paused: bool) -> Result<(), String> {
indexer::set_worker_paused(&worker, paused);
Ok(())
}
#[tauri::command]
pub async fn get_worker_states() -> Result<WorkerStates, String> {
let states = indexer::get_worker_paused_states();
Ok(WorkerStates {
thumbnail_paused: states[0],
metadata_paused: states[1],
embedding_paused: states[2],
})
}
+2
View File
@@ -813,6 +813,8 @@ pub fn get_images(
"date_desc" => "modified_at DESC NULLS LAST",
"size_asc" => "file_size ASC",
"size_desc" => "file_size DESC",
"duration_asc" => "duration_ms ASC NULLS LAST",
"duration_desc" => "duration_ms DESC NULLS LAST",
_ => "modified_at DESC NULLS LAST",
};
+25
View File
@@ -4,9 +4,11 @@ use candle_nn::VarBuilder;
use candle_transformers::models::clip::{self, ClipModel};
use hf_hub::{api::sync::Api, Repo, RepoType};
use std::path::{Path, PathBuf};
use tokenizers::Tokenizer;
pub struct ClipImageEmbedder {
model: ClipModel,
tokenizer: Tokenizer,
device: Device,
image_size: usize,
}
@@ -21,6 +23,11 @@ impl ClipImageEmbedder {
));
println!("Resolving CLIP model weights from Hugging Face cache...");
let model_path = repo.get("model.safetensors")?;
let tokenizer_repo = api.repo(Repo::new(
"openai/clip-vit-base-patch32".to_string(),
RepoType::Model,
));
let tokenizer_path = tokenizer_repo.get("tokenizer.json")?;
let config = clip::ClipConfig::vit_base_patch32();
let device = resolve_device()?;
@@ -32,10 +39,12 @@ impl ClipImageEmbedder {
)?
};
let model = ClipModel::new(vb, &config)?;
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?;
println!("CLIP image embedder ready.");
Ok(Self {
model,
tokenizer,
device,
image_size: config.image_size,
})
@@ -56,6 +65,22 @@ impl ClipImageEmbedder {
}
Ok(embeddings)
}
pub fn embed_text(&self, query: &str) -> Result<Vec<f32>> {
let encoding = self
.tokenizer
.encode(query, true)
.map_err(anyhow::Error::msg)?;
let token_ids = encoding
.get_ids()
.iter()
.map(|token| *token as u32)
.collect::<Vec<_>>();
let input_ids = Tensor::new(vec![token_ids], &self.device)?;
let features = self.model.get_text_features(&input_ids)?;
let normalized = clip::div_l2_norm(&features)?;
Ok(normalized.flatten_all()?.to_vec1::<f32>()?)
}
}
fn resolve_device() -> Result<Device> {
+35 -4
View File
@@ -9,6 +9,7 @@ use rayon::prelude::*;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
@@ -24,6 +25,27 @@ const JOB_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(750);
static LAST_JOB_PROGRESS_EMIT: OnceLock<Mutex<HashMap<i64, Instant>>> = OnceLock::new();
static ACTIVE_INDEXING_FOLDERS: OnceLock<Mutex<HashSet<i64>>> = OnceLock::new();
static THUMBNAIL_WORKER_PAUSED: AtomicBool = AtomicBool::new(false);
static METADATA_WORKER_PAUSED: AtomicBool = AtomicBool::new(false);
static EMBEDDING_WORKER_PAUSED: AtomicBool = AtomicBool::new(false);
pub fn set_worker_paused(worker: &str, paused: bool) {
match worker {
"thumbnail" => THUMBNAIL_WORKER_PAUSED.store(paused, Ordering::Relaxed),
"metadata" => METADATA_WORKER_PAUSED.store(paused, Ordering::Relaxed),
"embedding" => EMBEDDING_WORKER_PAUSED.store(paused, Ordering::Relaxed),
_ => {}
}
}
pub fn get_worker_paused_states() -> [bool; 3] {
[
THUMBNAIL_WORKER_PAUSED.load(Ordering::Relaxed),
METADATA_WORKER_PAUSED.load(Ordering::Relaxed),
EMBEDDING_WORKER_PAUSED.load(Ordering::Relaxed),
]
}
static FOLDER_STORAGE_PROFILES: OnceLock<Mutex<HashMap<i64, RuntimeAdaptiveProfile>>> =
OnceLock::new();
static DB_WRITE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -73,20 +95,26 @@ pub fn start_thumbnail_worker(
cache_dir: PathBuf,
) {
std::thread::spawn(move || loop {
if THUMBNAIL_WORKER_PAUSED.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(500));
continue;
}
if let Err(error) = process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) {
eprintln!("Thumbnail worker error: {}", error);
}
std::thread::sleep(std::time::Duration::from_millis(250));
});
}
pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaTools) {
std::thread::spawn(move || loop {
if METADATA_WORKER_PAUSED.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(500));
continue;
}
if let Err(error) = process_metadata_batch(&app, &pool, &media_tools) {
eprintln!("Metadata worker error: {}", error);
}
std::thread::sleep(std::time::Duration::from_millis(250));
});
}
@@ -96,10 +124,13 @@ pub fn start_embedding_worker(app: AppHandle, pool: DbPool) {
let mut embedder: Option<ClipImageEmbedder> = None;
println!("Embedding worker started.");
loop {
if EMBEDDING_WORKER_PAUSED.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(500));
continue;
}
if let Err(error) = process_embedding_batch(&app, &pool, &mut embedder) {
eprintln!("Embedding worker error: {}", error);
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
});
@@ -319,7 +350,7 @@ fn process_thumbnail_batch(
return Ok(());
}
println!("Embedding batch claimed: {} items", jobs.len());
println!("Thumbnail batch claimed: {} items", jobs.len());
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
jobs.into_iter().partition(|job| job.media_kind == "image");
+3
View File
@@ -74,6 +74,9 @@ pub fn run() {
commands::update_image_details,
commands::find_similar_images,
commands::retry_failed_embeddings,
commands::semantic_search_images,
commands::set_worker_paused,
commands::get_worker_states,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+19 -2
View File
@@ -6,6 +6,13 @@ use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Command;
// On Windows, GUI apps spawn subprocesses with a visible console window by default.
// CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation.
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[derive(Debug, Clone)]
pub struct MediaTools {
ffmpeg_path: PathBuf,
@@ -30,6 +37,10 @@ impl MediaTools {
}
pub fn ensure_installed() -> Result<()> {
// Skip download entirely if both binaries are already present.
if ffmpeg_path().exists() && ffprobe_path().exists() {
return Ok(());
}
auto_download_with_progress(|event| match event {
FfmpegDownloadProgressEvent::Starting => {
println!("Downloading bundled FFmpeg...");
@@ -53,11 +64,17 @@ impl MediaTools {
}
pub fn ffmpeg_command(&self) -> Command {
Command::new(&self.ffmpeg_path)
let mut cmd = Command::new(&self.ffmpeg_path);
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
pub fn ffprobe_command(&self) -> Command {
Command::new(&self.ffprobe_path)
let mut cmd = Command::new(&self.ffprobe_path);
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
}
+32
View File
@@ -78,6 +78,38 @@ pub fn find_similar_image_ids(conn: &Connection, image_id: i64, limit: usize) ->
Ok(ids)
}
pub fn search_image_ids_by_embedding(
conn: &Connection,
embedding: &[f32],
limit: usize,
) -> Result<Vec<i64>> {
if embedding.len() != CLIP_VECTOR_DIM {
return Err(anyhow!(
"expected {}-dimensional embedding, got {}",
CLIP_VECTOR_DIM,
embedding.len()
));
}
let packed = pack_f32(embedding);
let mut stmt = conn.prepare(
"SELECT image_id
FROM image_vec
WHERE embedding MATCH vec_f32(?1)
AND k = ?2",
)?;
let rows = stmt.query_map((&packed, limit as i64), |row| row.get::<_, i64>(0))?;
let mut ids = Vec::new();
for row in rows {
ids.push(row?);
if ids.len() >= limit {
break;
}
}
Ok(ids)
}
#[allow(dead_code)]
fn pack_f32(values: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(values.len() * std::mem::size_of::<f32>());
-2
View File
@@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useGalleryStore } from "./store";
import { Sidebar } from "./components/Sidebar";
import { BackgroundTasks } from "./components/BackgroundTasks";
import { MenuBar } from "./components/MenuBar";
import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox";
@@ -31,7 +30,6 @@ export default function App() {
<div className="flex h-screen bg-gray-950 text-white overflow-hidden select-none">
<Sidebar />
<main className="flex-1 flex flex-col min-w-0">
<MenuBar />
<Toolbar />
<BackgroundTasks />
<Gallery />
+348 -94
View File
@@ -1,15 +1,32 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store";
function ProgressBar({ value }: { value: number }) {
return (
<div className="h-1.5 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-blue-400 transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, value))}%` }}
/>
</div>
);
type WorkerKey = "thumbnail" | "metadata" | "embedding";
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
Thumbnails: "thumbnail",
Metadata: "metadata",
Embeddings: "embedding",
};
interface TaskStage {
label: string;
detail: string;
progress: number | null; // 0100, or null for indeterminate
failed: boolean;
}
interface Task {
id: number;
name: string;
stages: TaskStage[];
hasFailedEmbeddings: boolean;
pendingMediaWork: number;
embeddingProcessed: number;
embeddingTotal: number;
currentFile: string | null;
snapshot: string;
}
export function BackgroundTasks() {
@@ -17,114 +34,351 @@ export function BackgroundTasks() {
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [paused, setPaused] = useState<Record<WorkerKey, boolean>>({
thumbnail: false,
metadata: false,
embedding: false,
});
const tasks = useMemo(() => {
useEffect(() => {
invoke<{ thumbnail_paused: boolean; metadata_paused: boolean; embedding_paused: boolean }>(
"get_worker_states",
).then((states) => {
setPaused({
thumbnail: states.thumbnail_paused,
metadata: states.metadata_paused,
embedding: states.embedding_paused,
});
});
}, []);
const toggleWorker = (worker: WorkerKey) => {
const next = !paused[worker];
setPaused((prev) => ({ ...prev, [worker]: next }));
void invoke("set_worker_paused", { worker, paused: next });
};
const dismissTask = (id: number, snapshot: string) => {
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
setExpanded(false);
};
const tasks = useMemo<Task[]>(() => {
return folders
.map((folder) => {
.map((folder): Task | null => {
const index = indexingProgress[folder.id];
const jobs = mediaJobProgress[folder.id];
const pendingMediaWork =
(jobs?.thumbnail_pending ?? 0) +
(jobs?.metadata_pending ?? 0) +
(jobs?.embedding_pending ?? 0);
const embeddingProcessed = (jobs?.embedding_ready ?? 0) + (jobs?.embedding_failed ?? 0);
const embeddingTotal = embeddingProcessed + (jobs?.embedding_pending ?? 0);
const hasFailedEmbeddings = (jobs?.embedding_failed ?? 0) > 0;
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) {
return null;
const thumbnailPending = jobs?.thumbnail_pending ?? 0;
const metadataPending = jobs?.metadata_pending ?? 0;
const embeddingPending = jobs?.embedding_pending ?? 0;
const embeddingReady = jobs?.embedding_ready ?? 0;
const embeddingFailed = jobs?.embedding_failed ?? 0;
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending;
const embeddingProcessed = embeddingReady + embeddingFailed;
const embeddingTotal = embeddingProcessed + embeddingPending;
const hasFailedEmbeddings = embeddingFailed > 0;
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) return null;
const stages: TaskStage[] = [];
if (index && !index.done) {
const pct = index.total > 0 ? (index.indexed / index.total) * 100 : 0;
stages.push({
label: "Scanning",
detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`,
progress: pct,
failed: false,
});
}
const indexPercent = index && index.total > 0 ? (index.indexed / index.total) * 100 : 0;
const embeddingPercent = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0;
if (thumbnailPending > 0) {
stages.push({
label: "Thumbnails",
detail: thumbnailPending.toLocaleString(),
progress: null,
failed: false,
});
}
if (metadataPending > 0) {
stages.push({
label: "Metadata",
detail: metadataPending.toLocaleString(),
progress: null,
failed: false,
});
}
if (embeddingPending > 0) {
const pct = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0;
stages.push({
label: "Embeddings",
detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`,
progress: pct,
failed: false,
});
}
if (hasFailedEmbeddings && pendingMediaWork === 0) {
stages.push({
label: "Failed",
detail: `${embeddingFailed.toLocaleString()} embeddings`,
progress: null,
failed: true,
});
}
const snapshot = `${pendingMediaWork}:${embeddingFailed}`;
return {
id: folder.id,
name: folder.name,
index,
jobs,
stages,
hasFailedEmbeddings,
pendingMediaWork,
indexPercent,
embeddingProcessed,
embeddingTotal,
embeddingPercent,
hasFailedEmbeddings,
currentFile: index && !index.done ? (index.current_file || null) : null,
snapshot,
};
})
.filter((task) => task !== null);
}, [folders, indexingProgress, mediaJobProgress]);
.filter((t): t is Task => t !== null)
.filter((t) => dismissed[t.id] !== t.snapshot);
}, [folders, indexingProgress, mediaJobProgress, dismissed]);
if (tasks.length === 0) {
return null;
}
if (tasks.length === 0) return null;
const primary = tasks[0];
const extraCount = tasks.length - 1;
const hasFailed = tasks.some((t) => t.hasFailedEmbeddings && t.pendingMediaWork === 0);
// Best progress bar value: use embedding progress if available (most informative),
// otherwise fall back to scanning progress, otherwise indeterminate.
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
const barProgress = embeddingStage?.progress ?? scanningStage?.progress ?? null;
return (
<div className="border-b border-white/5 bg-gray-950/40 px-5 py-2 backdrop-blur-xl">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Background Tasks</h3>
<span className="text-xs text-gray-500">{tasks.length} active</span>
</div>
<div className="grid gap-2 xl:grid-cols-2">
{tasks.map((task) => (
<div key={task.id} className="rounded-xl border border-white/8 bg-white/[0.03] px-3 py-2.5">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{task.name}</p>
<p className="text-[11px] text-gray-500">
{task.index && !task.index.done
? `${task.index.indexed.toLocaleString()} of ${task.index.total.toLocaleString()} scanned`
: task.hasFailedEmbeddings && task.pendingMediaWork === 0
? `Embedding failures require attention`
: `${task.pendingMediaWork.toLocaleString()} media jobs remaining`}
</p>
</div>
<div className="text-right text-[11px] text-gray-400">
{task.jobs?.thumbnail_pending ? <div>{task.jobs.thumbnail_pending.toLocaleString()} thumbnails</div> : null}
{task.jobs?.metadata_pending ? <div>{task.jobs.metadata_pending.toLocaleString()} metadata</div> : null}
{task.embeddingTotal > 0 ? (
<div>
{task.embeddingProcessed.toLocaleString()} / {task.embeddingTotal.toLocaleString()} embeddings
</div>
) : null}
{task.jobs?.embedding_failed ? <div>{task.jobs.embedding_failed.toLocaleString()} failed</div> : null}
</div>
</div>
<div className="shrink-0 border-b border-white/[0.06]">
{/* Slim bar */}
<div
className={`group flex items-center gap-3 px-5 h-11 cursor-pointer select-none transition-colors ${
expanded ? "bg-white/[0.03]" : "hover:bg-white/[0.02]"
}`}
onClick={() => setExpanded((v) => !v)}
>
{/* Pulse dot */}
<div className="relative shrink-0">
<div className={`h-1.5 w-1.5 rounded-full ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} />
<div className={`absolute inset-0 h-1.5 w-1.5 rounded-full animate-ping opacity-60 ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} />
</div>
{task.index && !task.index.done ? (
<div className="mt-2 space-y-1">
<ProgressBar value={task.indexPercent} />
<p className="truncate text-[11px] text-gray-500">{task.index.current_file || "Scanning..."}</p>
</div>
) : task.embeddingTotal > 0 && (task.jobs?.embedding_pending ?? 0) > 0 ? (
<div className="mt-2 space-y-1">
<ProgressBar value={task.embeddingPercent} />
<p className="text-[11px] text-gray-500">
{task.embeddingProcessed.toLocaleString()} completed, {task.jobs?.embedding_pending?.toLocaleString() ?? 0} remaining
</p>
</div>
) : task.hasFailedEmbeddings ? (
<div className="mt-2 space-y-1">
<ProgressBar value={100} />
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-amber-300">
{task.jobs?.embedding_failed?.toLocaleString() ?? 0} embedding failures need attention
</p>
{/* Folder name */}
<span className="text-[13px] font-medium text-white/60 shrink-0">{primary.name}</span>
{/* Stage tags — all active stages visible simultaneously */}
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
{primary.stages.map((stage) => {
const workerKey = WORKER_FOR_STAGE[stage.label];
const isPaused = workerKey ? paused[workerKey] : false;
return (
<span
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"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-400"
}`}
>
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
<button
className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-200 hover:bg-amber-500/20"
onClick={() => void retryFailedEmbeddings(task.id)}
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
onClick={(e) => { e.stopPropagation(); toggleWorker(workerKey); }}
>
Retry
{isPaused ? (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
</button>
)}
</span>
);
})}
</div>
{/* Progress bar — embedding or scanning progress, pulsing if indeterminate */}
<div className="w-24 h-px bg-white/8 rounded-full overflow-hidden shrink-0">
<div
className={`h-full rounded-full transition-all duration-500 ${
hasFailed
? "bg-amber-400/60"
: barProgress === null
? "bg-blue-500/40 animate-pulse w-full"
: "bg-blue-500"
}`}
style={barProgress !== null ? { width: `${barProgress}%` } : undefined}
/>
</div>
{/* Extra folders badge */}
{extraCount > 0 && (
<span className="rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-gray-500 shrink-0">
+{extraCount}
</span>
)}
{/* Retry (failed embeddings only) */}
{primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
onClick={(e) => { e.stopPropagation(); void retryFailedEmbeddings(primary.id); }}
>
Retry
</button>
)}
{/* Expand chevron (only when multiple folders) */}
{tasks.length > 1 && (
<svg
className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
{/* Dismiss */}
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Expanded panel — one row per folder */}
{expanded && (
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
{tasks.map((task) => {
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
const taskBarProgress = taskEmbeddingStage?.progress ?? taskScanningStage?.progress ?? null;
const taskHasFailed = task.hasFailedEmbeddings && task.pendingMediaWork === 0;
return (
<div key={task.id}>
<div className="flex items-center gap-3">
<span className="text-[12px] text-white/50 w-28 truncate shrink-0">{task.name}</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
{task.stages.map((stage) => {
const workerKey = WORKER_FOR_STAGE[stage.label];
const isPaused = workerKey ? paused[workerKey] : false;
return (
<span
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"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-500"
}`}
>
{isPaused && (
<svg className="h-2 w-2 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
<button
className="ml-0.5 text-gray-600 hover:text-white transition-colors"
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
onClick={() => toggleWorker(workerKey)}
>
{isPaused ? (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
</button>
)}
</span>
);
})}
</div>
<div className="w-20 h-px bg-white/8 rounded-full overflow-hidden shrink-0">
<div
className={`h-full rounded-full transition-all duration-500 ${
taskHasFailed
? "bg-amber-400/60"
: taskBarProgress === null
? "bg-blue-500/40 animate-pulse w-full"
: "bg-blue-500"
}`}
style={taskBarProgress !== null ? { width: `${taskBarProgress}%` } : undefined}
/>
</div>
{taskHasFailed && (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
onClick={() => void retryFailedEmbeddings(task.id)}
>
Retry
</button>
)}
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={() => dismissTask(task.id, task.snapshot)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{task.currentFile && (
<p className="text-[10px] text-gray-600 truncate mt-1 pl-[calc(7rem+0.75rem)]">
{task.currentFile}
</p>
)}
</div>
) : task.pendingMediaWork > 0 ? (
<div className="mt-2 space-y-1">
<ProgressBar value={0} />
<p className="text-[11px] text-gray-500">Processing thumbnails, metadata, and embeddings</p>
</div>
) : null}
</div>
))}
</div>
);
})}
</div>
)}
</div>
);
}
+104 -137
View File
@@ -2,27 +2,7 @@ import { useEffect, useRef, useCallback, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
const GAP = 8;
function RatingStars({ rating }: { rating: number }) {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, index) => {
const filled = index < rating;
return (
<svg
key={index}
className={`h-3 w-3 ${filled ? "text-amber-300" : "text-white/25"}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
);
})}
</div>
);
}
const GAP = 6;
function formatDuration(durationMs: number | null): string | null {
if (!durationMs || durationMs <= 0) return null;
@@ -30,27 +10,12 @@ function formatDuration(durationMs: number | null): string | null {
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function embeddingLabel(image: ImageRecord): string {
if (image.embedding_status === "ready") {
return image.embedding_model ? `Embeddings ready` : "Embeddings ready";
}
if (image.embedding_status === "failed") {
return "Embeddings failed";
}
if (image.embedding_status === "processing") {
return "Embedding...";
}
return "Embedding queued";
}
function ContextMenu({
x,
y,
@@ -65,60 +30,57 @@ function ContextMenu({
const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const canFindSimilar = image.embedding_status === "ready";
return (
<div
data-gallery-context-menu
className="fixed z-40 min-w-56 rounded-2xl border border-white/10 bg-gray-950/95 p-2 shadow-2xl backdrop-blur"
className="fixed z-40 min-w-52 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur"
style={{ left: x, top: y }}
onClick={(event) => event.stopPropagation()}
>
<button
className="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5"
onClick={() => {
openImage(image);
onClose();
}}
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => { openImage(image); onClose(); }}
>
Open Preview
</button>
<button
className="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5"
onClick={async () => {
await updateImageDetails(image.id, { favorite: !image.favorite });
onClose();
}}
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
onClick={async () => { await updateImageDetails(image.id, { favorite: !image.favorite }); onClose(); }}
>
{image.favorite ? "Remove Favorite" : "Add to Favorites"}
</button>
<button
className="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5"
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition-colors ${
canFindSimilar
? "text-gray-200 hover:bg-white/5 hover:text-white"
: "text-gray-600 cursor-not-allowed"
}`}
onClick={async () => {
if (!canFindSimilar) return;
await loadSimilarImages(image.id);
onClose();
}}
disabled={!canFindSimilar}
>
Find Similar
{canFindSimilar ? "Find Similar" : "Embeddings not ready"}
</button>
<div className="my-2 h-px bg-white/5" />
<div className="px-3 pb-1 pt-1 text-[11px] uppercase tracking-[0.2em] text-gray-500">Rating</div>
<div className="flex items-center gap-1 px-2 pb-1">
<div className="my-1 h-px bg-white/[0.06]" />
<div className="px-3 py-1 text-[10px] uppercase tracking-[0.18em] text-gray-600">Rating</div>
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<button
key={rating}
className="rounded-md p-1"
onClick={async () => {
await updateImageDetails(image.id, { rating });
onClose();
}}
className="rounded-md p-1 transition-colors hover:bg-white/5"
onClick={async () => { await updateImageDetails(image.id, { rating }); onClose(); }}
title={`Set ${rating} star rating`}
>
<svg
className={`h-5 w-5 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/50"}`}
fill="currentColor"
viewBox="0 0 20 20"
className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`}
fill="currentColor" viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
@@ -127,14 +89,11 @@ function ContextMenu({
})}
{image.rating > 0 ? (
<button
className="ml-auto rounded-lg p-1.5 text-gray-400 hover:bg-white/5 hover:text-white"
onClick={async () => {
await updateImageDetails(image.id, { rating: 0 });
onClose();
}}
className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors"
onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }}
title="Remove rating"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -156,6 +115,7 @@ function ImageTile({
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const canFindSimilar = image.embedding_status === "ready";
const src = image.thumbnail_path
? convertFileSrc(image.thumbnail_path)
@@ -165,94 +125,102 @@ function ImageTile({
return (
<button
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-white/[0.03] text-left"
className="group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none"
style={{ width: "100%", aspectRatio: "1 / 1" }}
onClick={onClick}
onContextMenu={onContextMenu}
title={image.filename}
>
{/* Image / placeholder */}
{src && !errored ? (
<>
{!loaded ? <div className="absolute inset-0 animate-pulse bg-white/5" /> : null}
{!loaded && <div className="absolute inset-0 animate-pulse bg-white/[0.04]" />}
<img
src={src}
alt={image.filename}
className={`h-full w-full object-cover transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
className={`h-full w-full object-cover transition-all duration-300 ${
loaded ? "opacity-100 scale-100" : "opacity-0 scale-[1.02]"
} group-hover:scale-[1.03]`}
loading="lazy"
onLoad={() => setLoaded(true)}
onError={() => setErrored(true)}
/>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-fuchsia-500/10 via-white/5 to-cyan-500/10 text-gray-300">
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03] text-white/20">
{image.media_kind === "video" ? (
<div className="rounded-full border border-white/10 bg-black/30 p-4 text-white shadow-lg">
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
<svg className="h-7 w-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-9 w-9" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
<svg className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}
</div>
)}
{image.media_kind === "video" ? (
{/* Video play icon — subtle at rest, visible on hover */}
{image.media_kind === "video" && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="rounded-full border border-white/10 bg-black/35 p-3 text-white shadow-lg backdrop-blur-sm">
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<div className="rounded-full bg-black/40 p-3 text-white backdrop-blur-sm opacity-50 group-hover:opacity-90 transition-opacity duration-200">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
) : null}
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/20 to-transparent opacity-80 transition-opacity group-hover:opacity-100" />
<div className="absolute left-3 right-3 top-3 flex items-start justify-between gap-2">
<div className="rounded-full border border-white/10 bg-black/35 px-2 py-1 text-[10px] uppercase tracking-[0.18em] text-white/80">
{image.media_kind}
</div>
<div className="flex items-center gap-1">
{image.media_kind === "video" && image.duration_ms ? (
<div className="rounded-full border border-white/10 bg-black/35 px-2 py-1 text-[10px] font-medium text-white/80">
{formatDuration(image.duration_ms)}
</div>
) : null}
{image.favorite ? (
<div className="rounded-full border border-white/10 bg-black/35 p-1 text-rose-300">
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg>
</div>
) : null}
</div>
{/* Persistent badges — only shown when meaningful */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none">
{image.favorite && (
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg>
</div>
)}
{image.media_kind === "video" && image.duration_ms && (
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
{formatDuration(image.duration_ms)}
</div>
)}
</div>
<div className="absolute bottom-0 left-0 right-0 p-3">
<p className="truncate text-sm font-medium text-white">{image.filename}</p>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-white/70">
<RatingStars rating={image.rating} />
<span>{embeddingLabel(image)}</span>
</div>
<div className="mt-2 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
{/* Hover overlay — slides up from bottom */}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
{/* Hover info — appears with overlay */}
<div className="absolute bottom-0 left-0 right-0 p-2.5 translate-y-1 group-hover:translate-y-0 opacity-0 group-hover:opacity-100 transition-all duration-200">
<p className="truncate text-[12px] font-medium text-white leading-tight">{image.filename}</p>
<div className="mt-1.5 flex items-center justify-between gap-2">
{image.rating > 0 ? (
<div className="flex items-center gap-0.5">
{Array.from({ length: image.rating }, (_, i) => (
<svg key={i} className="h-2.5 w-2.5 text-amber-300" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
) : (
<span />
)}
<button
className="rounded-full border border-white/10 bg-black/40 px-2.5 py-1 text-[11px] text-white/85 hover:bg-black/60"
className={`rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm ${
canFindSimilar
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
: "bg-white/5 text-white/30 cursor-not-allowed"
}`}
onClick={(event) => {
event.stopPropagation();
if (!canFindSimilar) return;
void loadSimilarImages(image.id);
}}
disabled={!canFindSimilar}
>
Find Similar
Similar
</button>
<span className="text-[11px] text-white/50">Right-click for more</span>
</div>
</div>
</button>
@@ -288,15 +256,11 @@ export function Gallery() {
useEffect(() => {
const close = (event: PointerEvent) => {
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) {
return;
}
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
setContextMenu(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setContextMenu(null);
}
if (event.key === "Escape") setContextMenu(null);
};
window.addEventListener("pointerdown", close);
window.addEventListener("keydown", handleKeyDown);
@@ -308,23 +272,21 @@ export function Gallery() {
if (images.length === 0 && !loadingImages) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-gray-600">
<svg className="h-16 w-16 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={0.75}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-sm">No media found</p>
<p className="text-xs opacity-60">Try another filter, or add a folder from the library menu.</p>
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
<svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30 font-medium">No media found</p>
<p className="text-xs text-white/15 mt-1">Try adjusting your filters or add a new folder</p>
</div>
</div>
);
}
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#060816]">
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
<div
className="grid content-start"
style={{
@@ -347,13 +309,18 @@ export function Gallery() {
</div>
{loadingImages ? (
<div className="flex justify-center py-6">
<div className="h-5 w-5 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
<div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
</div>
) : null}
{contextMenu ? (
<ContextMenu x={contextMenu.x} y={contextMenu.y} image={contextMenu.image} onClose={() => setContextMenu(null)} />
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
image={contextMenu.image}
onClose={() => setContextMenu(null)}
/>
) : null}
</div>
);
+12 -3
View File
@@ -56,6 +56,7 @@ export function Lightbox() {
const imageViewportRef = useRef<HTMLDivElement>(null);
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
const canFindSimilar = selectedImage?.embedding_status === "ready";
const goPrev = useCallback(() => {
if (currentIndex > 0) openImage(images[currentIndex - 1]);
@@ -199,10 +200,18 @@ export function Lightbox() {
</svg>
</button>
<button
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-gray-300 hover:text-white"
onClick={() => void loadSimilarImages(selectedImage.id)}
className={`rounded-full border px-3 py-1.5 text-xs ${
canFindSimilar
? "border-white/10 bg-white/5 text-gray-300 hover:text-white"
: "border-white/5 bg-white/[0.03] text-gray-600 cursor-not-allowed"
}`}
onClick={() => {
if (!canFindSimilar) return;
void loadSimilarImages(selectedImage.id);
}}
disabled={!canFindSimilar}
>
Similar
{canFindSimilar ? "Similar" : "Embeddings not ready"}
</button>
</div>
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
+58 -87
View File
@@ -15,75 +15,52 @@ function FolderItem({
return (
<div
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected
? "bg-blue-600 text-white"
: "hover:bg-white/5 text-gray-300 hover:text-white"
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => selectFolder(folder.id)}
>
<svg
className="w-4 h-4 shrink-0 opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
/>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium">{folder.name}</div>
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{folder.name}
</div>
{isIndexing ? (
<div className="text-xs opacity-60">
{progress.indexed}/{progress.total} indexed
</div>
<>
<div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div>
<div className="h-px bg-white/10 rounded mt-1.5 overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%` }}
/>
</div>
</>
) : (
<div className="text-xs opacity-50">
{folder.image_count.toLocaleString()} items
</div>
)}
{isIndexing && (
<div className="h-0.5 bg-white/20 rounded mt-1 overflow-hidden">
<div
className="h-full bg-blue-400 transition-all duration-300"
style={{
width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%`,
}}
/>
</div>
<div className="text-[11px] text-gray-600 mt-0.5">{folder.image_count.toLocaleString()}</div>
)}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
className="p-1 rounded hover:bg-white/10"
className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200"
title="Re-index"
onClick={(e) => {
e.stopPropagation();
reindexFolder(folder.id);
}}
onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
className="p-1 rounded hover:bg-red-500/20 hover:text-red-400"
className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400"
title="Remove folder"
onClick={(e) => {
e.stopPropagation();
removeFolder(folder.id);
}}
onClick={(e) => { e.stopPropagation(); removeFolder(folder.id); }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -99,56 +76,63 @@ export function Sidebar() {
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const addFolder = useGalleryStore((state) => state.addFolder);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const totalImages = useGalleryStore((state) => state.totalImages);
const selectFolder = useGalleryStore((state) => state.selectFolder);
const handleAddFolder = async () => {
const selected = await open({
directory: true,
multiple: false,
title: "Select Media Folder",
});
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
if (selected && typeof selected === "string") {
await addFolder(selected);
}
};
return (
<aside className="w-72 shrink-0 flex flex-col bg-gray-900 border-r border-white/5">
<aside className="w-60 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06]">
{/* Header */}
<div className="px-4 py-4 border-b border-white/5">
<h1 className="text-white font-semibold text-base">Image Gallery</h1>
<p className="text-gray-500 text-xs mt-0.5">
{folders.reduce((s, f) => s + f.image_count, 0).toLocaleString()} total items
</p>
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0">
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span>
<button
onClick={handleAddFolder}
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-white/8 transition-colors"
title="Add Media Folder"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{/* All photos link */}
<div className="px-3 pt-3">
{/* Nav */}
<div className="px-2 pt-2 pb-1">
<div
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selectedFolderId === null
? "bg-blue-600 text-white"
: "hover:bg-white/5 text-gray-300 hover:text-white"
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => selectFolder(null)}
>
<svg className="w-4 h-4 shrink-0 opacity-70" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm font-medium">All Media</span>
{selectedFolderId === null && (
<span className="ml-auto text-xs opacity-60">{totalImages.toLocaleString()}</span>
)}
<span className={`text-[13px] font-medium ${selectedFolderId === null ? "text-white" : ""}`}>
All Media
</span>
</div>
</div>
{/* Section label */}
{folders.length > 0 && (
<div className="px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span>
</div>
)}
{/* Folder list */}
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-0.5 min-h-0">
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0">
{folders.length === 0 ? (
<p className="text-gray-600 text-xs px-3 py-4 text-center">
No folders added yet
<p className="text-gray-700 text-xs px-3 py-6 text-center leading-relaxed">
Add a folder to get started
</p>
) : (
folders.map((folder) => (
@@ -161,19 +145,6 @@ export function Sidebar() {
))
)}
</div>
{/* Add folder button */}
<div className="px-3 pb-4 pt-2 border-t border-white/5">
<button
onClick={handleAddFolder}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Media Folder
</button>
</div>
</aside>
);
}
+224 -84
View File
@@ -1,16 +1,93 @@
import { useEffect, useRef, useState } from "react";
import { tileSizeForZoom, useGalleryStore, SortOrder } from "../store";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchMode } from "../store";
const SORT_OPTIONS: { value: SortOrder; label: string }[] = [
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" },
{ value: "date_asc", label: "Oldest first" },
{ value: "name_asc", label: "Name A-Z" },
{ value: "name_desc", label: "Name Z-A" },
{ value: "name_asc", label: "Name AZ" },
{ value: "name_desc", label: "Name ZA" },
{ value: "size_desc", label: "Largest first" },
{ value: "size_asc", label: "Smallest first" },
];
function FilterChip({
const VIDEO_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "duration_desc", label: "Longest first" },
{ value: "duration_asc", label: "Shortest first" },
];
function getSortOptions(mediaFilter: MediaFilter) {
if (mediaFilter === "video") {
return [...BASE_SORT_OPTIONS, ...VIDEO_SORT_OPTIONS];
}
return BASE_SORT_OPTIONS;
}
function SortDropdown({
value,
onChange,
options,
}: {
value: SortOrder;
onChange: (v: SortOrder) => void;
options: { value: SortOrder; label: string }[];
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((o) => o.value === value) ?? BASE_SORT_OPTIONS[0];
useEffect(() => {
const close = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
}`}
>
<span>{current?.label ?? "Sort"}</span>
<svg
className={`h-3 w-3 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur">
{options.map((option) => (
<button
key={option.value}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
option.value === value
? "bg-white/6 text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={() => { onChange(option.value); setOpen(false); }}
>
{option.label}
{option.value === value ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
))}
</div>
) : null}
</div>
);
}
function FilterPill({
label,
active,
onClick,
@@ -21,10 +98,10 @@ function FilterChip({
}) {
return (
<button
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active
? "border-blue-400/50 bg-blue-500/15 text-white"
: "border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white"
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={onClick}
>
@@ -36,6 +113,9 @@ function FilterChip({
export function Toolbar() {
const search = useGalleryStore((state) => state.search);
const setSearch = useGalleryStore((state) => state.setSearch);
const clearSearch = useGalleryStore((state) => state.clearSearch);
const searchMode = useGalleryStore((state) => state.searchMode);
const setSearchMode = useGalleryStore((state) => state.setSearchMode);
const sort = useGalleryStore((state) => state.sort);
const setSort = useGalleryStore((state) => state.setSort);
const totalImages = useGalleryStore((state) => state.totalImages);
@@ -52,101 +132,161 @@ export function Toolbar() {
const [searchValue, setSearchValue] = useState(search);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media");
const tileSize = tileSizeForZoom(zoomPreset);
const sortOptions = getSortOptions(mediaFilter);
const hasActiveSearch = search.trim().length > 0;
const searchModes: { value: SearchMode; label: string }[] = [
{ value: "filename", label: "Filename" },
{ value: "semantic", label: "Semantic" },
];
// If current sort is video-only but we switched away from video filter, reset to date_desc
useEffect(() => {
if (mediaFilter !== "video" && (sort === "duration_asc" || sort === "duration_desc")) {
setSort("date_desc");
}
}, [mediaFilter, sort, setSort]);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setSearch(searchValue);
}, 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [searchValue, setSearch]);
useEffect(() => {
setSearchValue(search);
}, [search]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isModeToggle = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "s";
if (isModeToggle) {
event.preventDefault();
setSearchMode(searchMode === "semantic" ? "filename" : "semantic");
searchInputRef.current?.focus();
return;
}
const activeElement = document.activeElement;
const searchFocused = activeElement === searchInputRef.current;
if (event.key === "Escape" && (searchFocused || hasActiveSearch)) {
event.preventDefault();
setSearchValue("");
clearSearch();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [clearSearch, hasActiveSearch, searchMode, setSearchMode]);
return (
<div className="border-b border-white/5 bg-gray-950/60 px-5 py-4 backdrop-blur-xl shrink-0">
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h2 className="truncate text-base font-semibold text-white">{title}</h2>
<div className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] uppercase tracking-[0.16em] text-gray-400">
{favoritesOnly ? "Favorites" : mediaFilter === "all" ? "Mixed Library" : mediaFilter}
</div>
</div>
<p className="mt-1 text-xs text-gray-500">
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
{/* Primary row */}
<div className="flex items-center gap-3 px-5 h-12">
{/* Title + count */}
<div className="flex items-baseline gap-2.5 min-w-0 shrink-0">
<h2 className="text-[15px] font-semibold text-white truncate max-w-48">{title}</h2>
<span className="text-xs text-gray-600 shrink-0">
{loadedCount < totalImages
? `Showing ${loadedCount.toLocaleString()} of ${totalImages.toLocaleString()} items`
: `${totalImages.toLocaleString()} items ready`}
</p>
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
: totalImages.toLocaleString()}
</span>
{(hasActiveSearch || searchMode === "semantic") && (
<span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0">
{searchMode === "semantic" ? "Semantic Search" : "Filename Search"}
</span>
)}
</div>
<div className="relative">
<svg
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z"
<div className="flex-1" />
{/* Search */}
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
<div className="flex items-center pl-2 pr-1 gap-1 border-r border-white/8">
{searchModes.map((mode) => (
<button
key={mode.value}
className={`rounded-md px-2 py-1 text-[11px] transition-colors ${
searchMode === mode.value
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200"
}`}
title={mode.value === "semantic" ? "Toggle with Ctrl/Cmd+Shift+S" : "Toggle with Ctrl/Cmd+Shift+S"}
onClick={() => setSearchMode(mode.value)}
>
{mode.label}
</button>
))}
</div>
<div className="relative">
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z" />
</svg>
<input
ref={searchInputRef}
type="text"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."}
className="w-64 bg-transparent py-1.5 pl-8 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors"
/>
</svg>
<input
type="text"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Search filenames, scenes, references..."
className="w-72 rounded-xl border border-white/10 bg-white/5 py-2 pl-9 pr-4 text-sm text-white placeholder:text-gray-500 focus:border-blue-500 focus:outline-none"
/>
{searchValue.trim().length > 0 && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
title="Clear search"
onClick={() => {
setSearchValue("");
clearSearch();
}}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<select
value={sort}
onChange={(event) => setSort(event.target.value as SortOrder)}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value} className="bg-gray-900">
{option.label}
</option>
{/* Sort */}
<SortDropdown value={sort} onChange={setSort} options={sortOptions} />
{/* Divider */}
<div className="h-4 w-px bg-white/10 shrink-0" />
{/* Zoom */}
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden">
{(["compact", "comfortable", "detail"] as const).map((preset, i) => (
<button
key={preset}
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${
i > 0 ? "border-l border-white/8" : ""
} ${
zoomPreset === preset
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
title={`${tileSize}px tiles`}
onClick={() => setZoomPreset(preset)}
>
{preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"}
</button>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<FilterChip label="All" active={mediaFilter === "all"} onClick={() => setMediaFilter("all")} />
<FilterChip label="Images" active={mediaFilter === "image"} onClick={() => setMediaFilter("image")} />
<FilterChip label="Videos" active={mediaFilter === "video"} onClick={() => setMediaFilter("video")} />
<FilterChip label="Favorites" active={favoritesOnly} onClick={() => setFavoritesOnly(!favoritesOnly)} />
<div className="ml-auto flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-400">
<span className="px-2">Tile {tileSize}px</span>
<button
className={`rounded-full px-2 py-1 ${zoomPreset === "compact" ? "bg-white/10 text-white" : "hover:text-white"}`}
onClick={() => setZoomPreset("compact")}
>
S
</button>
<button
className={`rounded-full px-2 py-1 ${zoomPreset === "comfortable" ? "bg-white/10 text-white" : "hover:text-white"}`}
onClick={() => setZoomPreset("comfortable")}
>
M
</button>
<button
className={`rounded-full px-2 py-1 ${zoomPreset === "detail" ? "bg-white/10 text-white" : "hover:text-white"}`}
onClick={() => setZoomPreset("detail")}
>
L
</button>
</div>
{/* Filter row */}
<div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => setFavoritesOnly(!favoritesOnly)} />
</div>
</div>
);
+51 -2
View File
@@ -14,6 +14,7 @@ export interface Folder {
export type MediaKind = "image" | "video";
export type MediaFilter = "all" | MediaKind;
export type ZoomPreset = "compact" | "comfortable" | "detail";
export type SearchMode = "filename" | "semantic";
export interface ImageRecord {
id: number;
@@ -77,7 +78,9 @@ export type SortOrder =
| "name_asc"
| "name_desc"
| "size_desc"
| "size_asc";
| "size_asc"
| "duration_desc"
| "duration_asc";
interface GalleryState {
folders: Folder[];
@@ -87,6 +90,7 @@ interface GalleryState {
loadedCount: number;
loadingImages: boolean;
search: string;
searchMode: SearchMode;
sort: SortOrder;
mediaFilter: MediaFilter;
favoritesOnly: boolean;
@@ -106,6 +110,9 @@ interface GalleryState {
loadImages: (reset?: boolean) => Promise<void>;
loadMoreImages: () => Promise<void>;
setSearch: (search: string) => void;
clearSearch: () => void;
resetSearch: () => void;
setSearchMode: (mode: SearchMode) => void;
setSort: (sort: SortOrder) => void;
setMediaFilter: (filter: MediaFilter) => void;
setFavoritesOnly: (favoritesOnly: boolean) => void;
@@ -171,6 +178,10 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number
return compareNullableNumber(a.file_size, b.file_size);
case "size_desc":
return compareNullableNumber(b.file_size, a.file_size);
case "duration_asc":
return compareNullableNumber(a.duration_ms, b.duration_ms);
case "duration_desc":
return compareNullableNumber(b.duration_ms, a.duration_ms);
default:
return compareNullableDate(b.modified_at, a.modified_at);
}
@@ -237,6 +248,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
loadedCount: 0,
loadingImages: false,
search: "",
searchMode: "filename",
sort: "date_desc",
mediaFilter: "all",
favoritesOnly: false,
@@ -292,10 +304,31 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly } = get();
const { selectedFolderId, search, searchMode, sort, loadedCount, mediaFilter, favoritesOnly } = get();
set({ loadingImages: true });
try {
if (searchMode === "semantic" && search.trim()) {
const images = await invoke<ImageRecord[]>("semantic_search_images", {
params: {
query: search,
folder_id: selectedFolderId,
media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly,
limit: PAGE_SIZE,
},
});
set({
images,
totalImages: images.length,
loadedCount: images.length,
loadingImages: false,
collectionTitle: `Semantic search: ${search}`,
});
return;
}
const offset = reset ? 0 : loadedCount;
const result = await invoke<{
images: ImageRecord[];
@@ -338,6 +371,21 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true);
},
clearSearch: () => {
set({ search: "", images: [], loadedCount: 0, collectionTitle: null });
void get().loadImages(true);
},
resetSearch: () => {
set({ search: "", searchMode: "filename", images: [], loadedCount: 0, collectionTitle: null });
void get().loadImages(true);
},
setSearchMode: (searchMode) => {
set({ searchMode, images: [], loadedCount: 0, collectionTitle: null });
void get().loadImages(true);
},
setSort: (sort) => {
set({ sort, images: [], loadedCount: 0, collectionTitle: null });
void get().loadImages(true);
@@ -369,6 +417,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
loadingImages: false,
collectionTitle: "Similar Images",
selectedFolderId: null,
selectedImage: null,
});
},