fix: add failed tag locate and filter controls

Add a failed-tag discovery flow for background worker failures.

Changes:
- Add a Failed Tags toolbar filter that appears when tag failures exist.
- Add Locate buttons for failed tag tasks in the background worker prompt.
- Route Locate to the affected folder and filter the gallery to images with tagger errors.
- Fetch and display failed tag filenames/errors in the expanded worker details.
- Add a backend query and gallery filter flag for images with failed AI tagging.
- Improve subtle-light contrast for failed worker chips, filenames, and Locate/Retry buttons.
- Also slightly increases the title bar update indicator pulse size for better visibility.
This commit is contained in:
2026-06-18 00:36:02 +01:00
parent c97fec2eb3
commit ca58c2ddd4
7 changed files with 238 additions and 68 deletions
+32
View File
@@ -38,6 +38,7 @@ pub struct GetImagesParams {
pub favorites_only: Option<bool>, pub favorites_only: Option<bool>,
pub rating_min: Option<i64>, pub rating_min: Option<i64>,
pub embedding_failed_only: Option<bool>, pub embedding_failed_only: Option<bool>,
pub tagging_failed_only: Option<bool>,
pub sort: Option<String>, pub sort: Option<String>,
pub offset: Option<i64>, pub offset: Option<i64>,
pub limit: Option<i64>, pub limit: Option<i64>,
@@ -329,6 +330,7 @@ pub async fn get_images(
let favorites_only = params.favorites_only.unwrap_or(false); let favorites_only = params.favorites_only.unwrap_or(false);
let rating_min = params.rating_min.unwrap_or(0); let rating_min = params.rating_min.unwrap_or(0);
let embedding_failed_only = params.embedding_failed_only.unwrap_or(false); let embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
let tagging_failed_only = params.tagging_failed_only.unwrap_or(false);
let total = db::count_images( let total = db::count_images(
&conn, &conn,
@@ -338,6 +340,7 @@ pub async fn get_images(
favorites_only, favorites_only,
rating_min, rating_min,
embedding_failed_only, embedding_failed_only,
tagging_failed_only,
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -349,6 +352,7 @@ pub async fn get_images(
favorites_only, favorites_only,
rating_min, rating_min,
embedding_failed_only, embedding_failed_only,
tagging_failed_only,
sort, sort,
offset, offset,
limit, limit,
@@ -594,6 +598,34 @@ pub async fn retry_failed_embeddings(
db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string()) db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string())
} }
#[derive(Serialize)]
pub struct FailedImageItem {
pub image_id: i64,
pub filename: String,
pub path: String,
pub error: Option<String>,
}
#[tauri::command]
pub async fn get_failed_tagging_images(
db: State<'_, DbState>,
folder_id: i64,
) -> Result<Vec<FailedImageItem>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::get_failed_tagging_images(&conn, folder_id)
.map(|rows| {
rows.into_iter()
.map(|(image_id, filename, path, error)| FailedImageItem {
image_id,
filename,
path,
error,
})
.collect()
})
.map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub async fn semantic_search_images( pub async fn semantic_search_images(
db: State<'_, DbState>, db: State<'_, DbState>,
+39 -4
View File
@@ -324,7 +324,10 @@ pub fn migrate(conn: &Connection) -> Result<()> {
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?; 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", "scan_error", "TEXT")?;
ensure_column(conn, "folders", "sort_order", "INTEGER NOT NULL DEFAULT 0")?; ensure_column(conn, "folders", "sort_order", "INTEGER NOT NULL DEFAULT 0")?;
conn.execute("UPDATE folders SET sort_order = id WHERE sort_order = 0", [])?; conn.execute(
"UPDATE folders SET sort_order = id WHERE sort_order = 0",
[],
)?;
vector::migrate(conn)?; vector::migrate(conn)?;
Ok(()) Ok(())
@@ -1629,6 +1632,7 @@ pub fn get_images(
favorites_only: bool, favorites_only: bool,
rating_min: i64, rating_min: i64,
embedding_failed_only: bool, embedding_failed_only: bool,
tagging_failed_only: bool,
sort: &str, sort: &str,
offset: i64, offset: i64,
limit: i64, limit: i64,
@@ -1652,6 +1656,7 @@ pub fn get_images(
let search_pattern = search.map(|value| format!("%{value}%")); let search_pattern = search.map(|value| format!("%{value}%"));
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only); let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only);
let sql = format!( let sql = format!(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
@@ -1665,8 +1670,9 @@ pub fn get_images(
AND (?4 = 0 OR favorite = 1) AND (?4 = 0 OR favorite = 1)
AND rating >= ?5 AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed') AND (?6 = 0 OR embedding_status = 'failed')
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)
ORDER BY {order} ORDER BY {order}
LIMIT ?7 OFFSET ?8" LIMIT ?8 OFFSET ?9"
); );
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map( let rows = stmt.query_map(
@@ -1677,6 +1683,7 @@ pub fn get_images(
favorites_flag, favorites_flag,
rating_min, rating_min,
embedding_failed_flag, embedding_failed_flag,
tagging_failed_flag,
limit, limit,
offset offset
], ],
@@ -1693,11 +1700,13 @@ pub fn count_images(
favorites_only: bool, favorites_only: bool,
rating_min: i64, rating_min: i64,
embedding_failed_only: bool, embedding_failed_only: bool,
tagging_failed_only: bool,
) -> Result<i64> { ) -> Result<i64> {
let search_pattern = search.map(|value| format!("%{value}%")); let search_pattern = search.map(|value| format!("%{value}%"));
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only); let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only);
let count = conn.query_row( let count = conn.query_row(
"SELECT COUNT(*) FROM images "SELECT COUNT(*) FROM images
WHERE (?1 IS NULL OR folder_id = ?1) WHERE (?1 IS NULL OR folder_id = ?1)
@@ -1705,14 +1714,16 @@ pub fn count_images(
AND (?3 IS NULL OR media_kind = ?3) AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1) AND (?4 = 0 OR favorite = 1)
AND rating >= ?5 AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed')", AND (?6 = 0 OR embedding_status = 'failed')
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)",
params![ params![
folder_id, folder_id,
search_pattern, search_pattern,
media_kind, media_kind,
favorites_flag, favorites_flag,
rating_min, rating_min,
embedding_failed_flag embedding_failed_flag,
tagging_failed_flag
], ],
|row| row.get(0), |row| row.get(0),
)?; )?;
@@ -1947,6 +1958,30 @@ pub fn get_failed_embedding_images(
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
} }
type FailedTaggingRow = (i64, String, String, Option<String>);
pub fn get_failed_tagging_images(
conn: &Connection,
folder_id: i64,
) -> Result<Vec<FailedTaggingRow>> {
let mut stmt = conn.prepare(
"SELECT i.id, i.filename, i.path, COALESCE(j.last_error, i.ai_tagger_error)
FROM images i
LEFT JOIN tagging_jobs j ON j.image_id = i.id AND j.status = 'failed'
WHERE i.folder_id = ?1 AND i.ai_tagger_error IS NOT NULL
ORDER BY i.filename",
)?;
let rows = stmt.query_map([folder_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn update_generated_caption( pub fn update_generated_caption(
conn: &Connection, conn: &Connection,
image_id: i64, image_id: i64,
+1
View File
@@ -162,6 +162,7 @@ pub fn run() {
commands::get_explore_tags, commands::get_explore_tags,
commands::get_images_by_ids, commands::get_images_by_ids,
commands::get_failed_embedding_images, commands::get_failed_embedding_images,
commands::get_failed_tagging_images,
commands::get_tagger_model_status, commands::get_tagger_model_status,
commands::get_tagger_acceleration, commands::get_tagger_acceleration,
commands::set_tagger_acceleration, commands::set_tagger_acceleration,
+94 -40
View File
@@ -31,24 +31,52 @@ interface Task {
snapshot: string; snapshot: string;
} }
interface FailedEmbeddingItem { interface FailedWorkerItem {
image_id: number; image_id: number;
filename: string; filename: string;
path: string; path: string;
error: string | null; error: string | null;
} }
function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
return (
<div className="flex min-w-0 items-start gap-1.5">
<svg className="mt-px h-2.5 w-2.5 shrink-0 text-amber-500 light-theme:text-amber-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-medium text-amber-400/80 light-theme:text-amber-700">{item.filename}</p>
{item.error && (
<p className="truncate text-[9px] text-gray-600">{item.error}</p>
)}
</div>
<button
className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100"
title="Reveal in Explorer"
onClick={() => void revealItemInDir(item.path)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</div>
);
}
export function BackgroundTasks() { export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders);
const indexingProgress = useGalleryStore((state) => state.indexingProgress); const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({}); const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({}); const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const workerPaused = useGalleryStore((state) => state.workerPaused); const workerPaused = useGalleryStore((state) => state.workerPaused);
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
@@ -58,27 +86,43 @@ export function BackgroundTasks() {
void loadWorkerStates(); void loadWorkerStates();
}, [folders, loadWorkerStates]); }, [folders, loadWorkerStates]);
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change. // Fetch failed filenames whenever the expanded panel opens or failure counts change.
const failedCounts = useMemo( const failedEmbeddingCounts = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]), Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]),
), ),
[mediaJobProgress], [mediaJobProgress],
); );
const failedTaggingCounts = useMemo(
() =>
Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]),
),
[mediaJobProgress],
);
useEffect(() => { useEffect(() => {
if (!expanded) return; if (!expanded) return;
for (const [folderId, count] of Object.entries(failedCounts)) { for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
if (count > 0) { if (count > 0) {
invoke<FailedEmbeddingItem[]>("get_failed_embedding_images", { invoke<FailedWorkerItem[]>("get_failed_embedding_images", {
folderId: Number(folderId), folderId: Number(folderId),
}) })
.then((items) => setFailedItems((prev) => ({ ...prev, [folderId]: items }))) .then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined); .catch(() => undefined);
} }
} }
}, [expanded, failedCounts]); for (const [folderId, count] of Object.entries(failedTaggingCounts)) {
if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_tagging_images", {
folderId: Number(folderId),
})
.then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
}
}
}, [expanded, failedEmbeddingCounts, failedTaggingCounts]);
const isWorkerPaused = (folderId: number, worker: WorkerKey) => { const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
return workerPaused[folderId]?.[worker] ?? false; return workerPaused[folderId]?.[worker] ?? false;
@@ -304,14 +348,14 @@ export function BackgroundTasks() {
key={stage.label} key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed stage.failed
? "bg-amber-500/10 text-amber-400" ? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused : isPaused
? "bg-white/4 text-gray-600" ? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-400" : "bg-white/5 text-gray-400"
}`} }`}
> >
<span>{stage.label}</span> <span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : isPaused ? "text-gray-700" : "text-gray-600"}`}> <span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
{stage.detail} {stage.detail}
</span> </span>
{workerKey && ( {workerKey && (
@@ -357,14 +401,27 @@ export function BackgroundTasks() {
</span> </span>
)} )}
{/* Retry (failed embeddings only) */} {primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && (
{primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && ( <div className="flex shrink-0 items-center gap-1.5">
{primary.hasFailedTagging ? (
<button <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" className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={(e) => { e.stopPropagation(); void retryFailedEmbeddings(primary.id); }} onClick={(e) => { e.stopPropagation(); showFailedTagging(primary.id); }}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={(e) => {
e.stopPropagation();
if (primary.hasFailedEmbeddings) void retryFailedEmbeddings(primary.id);
if (primary.hasFailedTagging) void queueTaggingJobs(primary.id);
}}
> >
Retry Retry
</button> </button>
</div>
)} )}
{/* Expand chevron (only when multiple tasks) */} {/* Expand chevron (only when multiple tasks) */}
@@ -416,7 +473,7 @@ export function BackgroundTasks() {
key={stage.label} key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed stage.failed
? "bg-amber-500/10 text-amber-400" ? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused : isPaused
? "bg-white/4 text-gray-600" ? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-500" : "bg-white/5 text-gray-500"
@@ -428,7 +485,7 @@ export function BackgroundTasks() {
</svg> </svg>
)} )}
<span>{stage.label}</span> <span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : "text-gray-600"}`}> <span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : "text-gray-600"}`}>
{stage.detail} {stage.detail}
</span> </span>
{workerKey && ( {workerKey && (
@@ -467,8 +524,17 @@ export function BackgroundTasks() {
</div> </div>
{taskHasFailed && ( {taskHasFailed && (
<div className="flex shrink-0 items-center gap-1.5">
{task.hasFailedTagging ? (
<button <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" className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => showFailedTagging(task.id)}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => { onClick={() => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id); if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
if (task.hasFailedTagging) void queueTaggingJobs(task.id); if (task.hasFailedTagging) void queueTaggingJobs(task.id);
@@ -476,6 +542,7 @@ export function BackgroundTasks() {
> >
Retry Retry
</button> </button>
</div>
)} )}
{task.id >= 0 && ( {task.id >= 0 && (
@@ -497,31 +564,18 @@ export function BackgroundTasks() {
</p> </p>
)} )}
{/* Failed embedding file list */} {/* Failed worker file lists */}
{taskHasFailed && failedItems[task.id] && failedItems[task.id].length > 0 && ( {taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5"> <div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
{failedItems[task.id].map((item) => ( {failedEmbeddingItems[task.id].map((item) => (
<div key={item.image_id} className="flex items-start gap-1.5 min-w-0"> <FailedWorkerItemRow key={item.image_id} item={item} />
<svg className="h-2.5 w-2.5 text-amber-500 shrink-0 mt-px" fill="none" viewBox="0 0 24 24" stroke="currentColor"> ))}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} </div>
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="min-w-0 flex-1">
<p className="text-[10px] text-amber-400/80 truncate font-medium">{item.filename}</p>
{item.error && (
<p className="text-[9px] text-gray-600 truncate">{item.error}</p>
)} )}
</div> {taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && (
<button <div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
className="shrink-0 text-gray-700 hover:text-gray-300 transition-colors" {failedTaggingItems[task.id].map((item) => (
title="Reveal in Explorer" <FailedWorkerItemRow key={item.image_id} item={item} />
onClick={() => void revealItemInDir(item.path)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</div>
))} ))}
</div> </div>
)} )}
+1 -1
View File
@@ -88,7 +88,7 @@ export function TitleBar() {
aria-label={`Update available — click to update to Phokus v${updateVersion}`} aria-label={`Update available — click to update to Phokus v${updateVersion}`}
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12" className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
> >
<span className="pointer-events-none absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" /> <span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" /> <PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button> </button>
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */} {/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
+17 -6
View File
@@ -158,6 +158,8 @@ export function Toolbar() {
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating); const setMinimumRating = useGalleryStore((state) => state.setMinimumRating);
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly); const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly); const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly);
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
const similarScope = useGalleryStore((state) => state.similarScope); const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope); const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
@@ -166,6 +168,7 @@ export function Toolbar() {
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
const hasAnyFailedTagging = Object.values(mediaJobProgress).some((p) => p.tagging_failed > 0);
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null); const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
const [searchQuery, setSearchQuery] = useState(search); const [searchQuery, setSearchQuery] = useState(search);
@@ -441,12 +444,12 @@ export function Toolbar() {
{/* Filter row */} {/* Filter row */}
<div className="flex items-center gap-1 px-4 pb-1.5"> <div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} /> <FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} /> <FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? ( {hasAnyFailedEmbeddings ? (
@@ -457,6 +460,14 @@ export function Toolbar() {
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)} onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
/> />
) : null} ) : null}
{hasAnyFailedTagging ? (
<FilterPill
label="Failed Tags"
active={failedTaggingOnly}
variant="amber"
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/>
) : null}
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null} {isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div> </div>
</div> </div>
+41 -4
View File
@@ -309,6 +309,7 @@ interface GalleryState {
favoritesOnly: boolean; favoritesOnly: boolean;
minimumRating: number; minimumRating: number;
failedEmbeddingsOnly: boolean; failedEmbeddingsOnly: boolean;
failedTaggingOnly: boolean;
zoomPreset: ZoomPreset; zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null; selectedImage: ImageRecord | null;
collectionTitle: string | null; collectionTitle: string | null;
@@ -403,6 +404,8 @@ interface GalleryState {
setFavoritesOnly: (favoritesOnly: boolean) => void; setFavoritesOnly: (favoritesOnly: boolean) => void;
setMinimumRating: (minimumRating: number) => void; setMinimumRating: (minimumRating: number) => void;
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void; setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
setFailedTaggingOnly: (failedTaggingOnly: boolean) => void;
showFailedTagging: (folderId: number) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void; setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void; openImage: (image: ImageRecord) => void;
closeImage: () => void; closeImage: () => void;
@@ -593,6 +596,7 @@ function matchesFilters(
favoritesOnly: boolean, favoritesOnly: boolean,
minimumRating: number, minimumRating: number,
failedEmbeddingsOnly: boolean, failedEmbeddingsOnly: boolean,
failedTaggingOnly: boolean,
search: string, search: string,
): boolean { ): boolean {
const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId; const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId;
@@ -600,7 +604,8 @@ function matchesFilters(
const matchesFavorite = !favoritesOnly || image.favorite; const matchesFavorite = !favoritesOnly || image.favorite;
const matchesRating = image.rating >= minimumRating; const matchesRating = image.rating >= minimumRating;
const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed"; const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed";
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesSearch(image, search); const matchesFailedTagging = !failedTaggingOnly || image.ai_tagger_error !== null;
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesFailedTagging && matchesSearch(image, search);
} }
function compareNullableNumber(a: number | null, b: number | null): number { function compareNullableNumber(a: number | null, b: number | null): number {
@@ -710,6 +715,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
favoritesOnly: false, favoritesOnly: false,
minimumRating: 0, minimumRating: 0,
failedEmbeddingsOnly: false, failedEmbeddingsOnly: false,
failedTaggingOnly: false,
zoomPreset: "comfortable", zoomPreset: "comfortable",
selectedImage: null, selectedImage: null,
collectionTitle: null, collectionTitle: null,
@@ -879,7 +885,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
selectFolder: (folderId) => { selectFolder: (folderId) => {
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null }); set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null });
void get().loadImages(true); void get().loadImages(true);
}, },
@@ -912,7 +918,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
loadImages: async (reset = false) => { loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, activeView } = get(); const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, activeView } = get();
const parsedSearch = parseSearchValue(search); const parsedSearch = parseSearchValue(search);
const requestToken = ++galleryRequestToken; const requestToken = ++galleryRequestToken;
set({ loadingImages: true, imageLoadError: null }); set({ loadingImages: true, imageLoadError: null });
@@ -999,6 +1005,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
favorites_only: favoritesOnly, favorites_only: favoritesOnly,
rating_min: minimumRating > 0 ? minimumRating : null, rating_min: minimumRating > 0 ? minimumRating : null,
embedding_failed_only: failedEmbeddingsOnly, embedding_failed_only: failedEmbeddingsOnly,
tagging_failed_only: failedTaggingOnly,
sort, sort,
offset, offset,
limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE, limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE,
@@ -1108,7 +1115,35 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => { setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); set({ failedEmbeddingsOnly, failedTaggingOnly: failedEmbeddingsOnly ? false : get().failedTaggingOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true);
},
setFailedTaggingOnly: (failedTaggingOnly) => {
set({ failedTaggingOnly, failedEmbeddingsOnly: failedTaggingOnly ? false : get().failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true);
},
showFailedTagging: (folderId) => {
set({
selectedFolderId: folderId,
activeView: "gallery",
search: "",
mediaFilter: "all",
favoritesOnly: false,
minimumRating: 0,
failedEmbeddingsOnly: false,
failedTaggingOnly: true,
images: [],
loadedCount: 0,
collectionTitle: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
similarCrop: null,
imageLoadError: null,
});
void get().loadImages(true); void get().loadImages(true);
}, },
@@ -2205,6 +2240,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
state.favoritesOnly, state.favoritesOnly,
state.minimumRating, state.minimumRating,
state.failedEmbeddingsOnly, state.failedEmbeddingsOnly,
state.failedTaggingOnly,
state.search, state.search,
), ),
); );
@@ -2244,6 +2280,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
state.favoritesOnly, state.favoritesOnly,
state.minimumRating, state.minimumRating,
state.failedEmbeddingsOnly, state.failedEmbeddingsOnly,
state.failedTaggingOnly,
state.search, state.search,
), ),
); );