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:
@@ -38,6 +38,7 @@ pub struct GetImagesParams {
|
||||
pub favorites_only: Option<bool>,
|
||||
pub rating_min: Option<i64>,
|
||||
pub embedding_failed_only: Option<bool>,
|
||||
pub tagging_failed_only: Option<bool>,
|
||||
pub sort: Option<String>,
|
||||
pub offset: 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 rating_min = params.rating_min.unwrap_or(0);
|
||||
let embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
|
||||
let tagging_failed_only = params.tagging_failed_only.unwrap_or(false);
|
||||
|
||||
let total = db::count_images(
|
||||
&conn,
|
||||
@@ -338,6 +340,7 @@ pub async fn get_images(
|
||||
favorites_only,
|
||||
rating_min,
|
||||
embedding_failed_only,
|
||||
tagging_failed_only,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -349,6 +352,7 @@ pub async fn get_images(
|
||||
favorites_only,
|
||||
rating_min,
|
||||
embedding_failed_only,
|
||||
tagging_failed_only,
|
||||
sort,
|
||||
offset,
|
||||
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())
|
||||
}
|
||||
|
||||
#[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]
|
||||
pub async fn semantic_search_images(
|
||||
db: State<'_, DbState>,
|
||||
|
||||
+39
-4
@@ -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);")?;
|
||||
ensure_column(conn, "folders", "scan_error", "TEXT")?;
|
||||
ensure_column(conn, "folders", "sort_order", "INTEGER NOT NULL DEFAULT 0")?;
|
||||
conn.execute("UPDATE folders SET sort_order = id WHERE sort_order = 0", [])?;
|
||||
conn.execute(
|
||||
"UPDATE folders SET sort_order = id WHERE sort_order = 0",
|
||||
[],
|
||||
)?;
|
||||
|
||||
vector::migrate(conn)?;
|
||||
Ok(())
|
||||
@@ -1629,6 +1632,7 @@ pub fn get_images(
|
||||
favorites_only: bool,
|
||||
rating_min: i64,
|
||||
embedding_failed_only: bool,
|
||||
tagging_failed_only: bool,
|
||||
sort: &str,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
@@ -1652,6 +1656,7 @@ pub fn get_images(
|
||||
let search_pattern = search.map(|value| format!("%{value}%"));
|
||||
let favorites_flag = i64::from(favorites_only);
|
||||
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||
let tagging_failed_flag = i64::from(tagging_failed_only);
|
||||
let sql = format!(
|
||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
|
||||
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
||||
@@ -1665,8 +1670,9 @@ pub fn get_images(
|
||||
AND (?4 = 0 OR favorite = 1)
|
||||
AND rating >= ?5
|
||||
AND (?6 = 0 OR embedding_status = 'failed')
|
||||
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)
|
||||
ORDER BY {order}
|
||||
LIMIT ?7 OFFSET ?8"
|
||||
LIMIT ?8 OFFSET ?9"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(
|
||||
@@ -1677,6 +1683,7 @@ pub fn get_images(
|
||||
favorites_flag,
|
||||
rating_min,
|
||||
embedding_failed_flag,
|
||||
tagging_failed_flag,
|
||||
limit,
|
||||
offset
|
||||
],
|
||||
@@ -1693,11 +1700,13 @@ pub fn count_images(
|
||||
favorites_only: bool,
|
||||
rating_min: i64,
|
||||
embedding_failed_only: bool,
|
||||
tagging_failed_only: bool,
|
||||
) -> Result<i64> {
|
||||
let search_pattern = search.map(|value| format!("%{value}%"));
|
||||
|
||||
let favorites_flag = i64::from(favorites_only);
|
||||
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||
let tagging_failed_flag = i64::from(tagging_failed_only);
|
||||
let count = conn.query_row(
|
||||
"SELECT COUNT(*) FROM images
|
||||
WHERE (?1 IS NULL OR folder_id = ?1)
|
||||
@@ -1705,14 +1714,16 @@ pub fn count_images(
|
||||
AND (?3 IS NULL OR media_kind = ?3)
|
||||
AND (?4 = 0 OR favorite = 1)
|
||||
AND rating >= ?5
|
||||
AND (?6 = 0 OR embedding_status = 'failed')",
|
||||
AND (?6 = 0 OR embedding_status = 'failed')
|
||||
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)",
|
||||
params![
|
||||
folder_id,
|
||||
search_pattern,
|
||||
media_kind,
|
||||
favorites_flag,
|
||||
rating_min,
|
||||
embedding_failed_flag
|
||||
embedding_failed_flag,
|
||||
tagging_failed_flag
|
||||
],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
@@ -1947,6 +1958,30 @@ pub fn get_failed_embedding_images(
|
||||
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(
|
||||
conn: &Connection,
|
||||
image_id: i64,
|
||||
|
||||
@@ -162,6 +162,7 @@ pub fn run() {
|
||||
commands::get_explore_tags,
|
||||
commands::get_images_by_ids,
|
||||
commands::get_failed_embedding_images,
|
||||
commands::get_failed_tagging_images,
|
||||
commands::get_tagger_model_status,
|
||||
commands::get_tagger_acceleration,
|
||||
commands::set_tagger_acceleration,
|
||||
|
||||
@@ -31,24 +31,52 @@ interface Task {
|
||||
snapshot: string;
|
||||
}
|
||||
|
||||
interface FailedEmbeddingItem {
|
||||
interface FailedWorkerItem {
|
||||
image_id: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
|
||||
return (
|
||||
<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() {
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
||||
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
|
||||
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging);
|
||||
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
|
||||
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<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 loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
|
||||
@@ -58,27 +86,43 @@ export function BackgroundTasks() {
|
||||
void loadWorkerStates();
|
||||
}, [folders, loadWorkerStates]);
|
||||
|
||||
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
|
||||
const failedCounts = useMemo(
|
||||
// Fetch failed filenames whenever the expanded panel opens or failure counts change.
|
||||
const failedEmbeddingCounts = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]),
|
||||
),
|
||||
[mediaJobProgress],
|
||||
);
|
||||
const failedTaggingCounts = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]),
|
||||
),
|
||||
[mediaJobProgress],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
for (const [folderId, count] of Object.entries(failedCounts)) {
|
||||
for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
|
||||
if (count > 0) {
|
||||
invoke<FailedEmbeddingItem[]>("get_failed_embedding_images", {
|
||||
invoke<FailedWorkerItem[]>("get_failed_embedding_images", {
|
||||
folderId: Number(folderId),
|
||||
})
|
||||
.then((items) => setFailedItems((prev) => ({ ...prev, [folderId]: items })))
|
||||
.then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}, [expanded, failedCounts]);
|
||||
for (const [folderId, count] of Object.entries(failedTaggingCounts)) {
|
||||
if (count > 0) {
|
||||
invoke<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) => {
|
||||
return workerPaused[folderId]?.[worker] ?? false;
|
||||
@@ -304,14 +348,14 @@ export function BackgroundTasks() {
|
||||
key={stage.label}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
|
||||
stage.failed
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
|
||||
: isPaused
|
||||
? "bg-white/4 text-gray-600"
|
||||
: "bg-white/5 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
{workerKey && (
|
||||
@@ -357,14 +401,27 @@ export function BackgroundTasks() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Retry (failed embeddings only) */}
|
||||
{primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && (
|
||||
{primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{primary.hasFailedTagging ? (
|
||||
<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); }}
|
||||
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(); 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
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expand chevron (only when multiple tasks) */}
|
||||
@@ -416,7 +473,7 @@ export function BackgroundTasks() {
|
||||
key={stage.label}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
|
||||
stage.failed
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
|
||||
: isPaused
|
||||
? "bg-white/4 text-gray-600"
|
||||
: "bg-white/5 text-gray-500"
|
||||
@@ -428,7 +485,7 @@ export function BackgroundTasks() {
|
||||
</svg>
|
||||
)}
|
||||
<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}
|
||||
</span>
|
||||
{workerKey && (
|
||||
@@ -467,8 +524,17 @@ export function BackgroundTasks() {
|
||||
</div>
|
||||
|
||||
{taskHasFailed && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{task.hasFailedTagging ? (
|
||||
<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={() => {
|
||||
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
|
||||
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
|
||||
@@ -476,6 +542,7 @@ export function BackgroundTasks() {
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.id >= 0 && (
|
||||
@@ -497,31 +564,18 @@ export function BackgroundTasks() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Failed embedding file list */}
|
||||
{taskHasFailed && failedItems[task.id] && failedItems[task.id].length > 0 && (
|
||||
{/* Failed worker file lists */}
|
||||
{taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && (
|
||||
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
|
||||
{failedItems[task.id].map((item) => (
|
||||
<div key={item.image_id} className="flex items-start gap-1.5 min-w-0">
|
||||
<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}
|
||||
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>
|
||||
{failedEmbeddingItems[task.id].map((item) => (
|
||||
<FailedWorkerItemRow key={item.image_id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 text-gray-700 hover:text-gray-300 transition-colors"
|
||||
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>
|
||||
{taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && (
|
||||
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
|
||||
{failedTaggingItems[task.id].map((item) => (
|
||||
<FailedWorkerItemRow key={item.image_id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function TitleBar() {
|
||||
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
|
||||
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
|
||||
>
|
||||
<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" />
|
||||
</button>
|
||||
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
|
||||
|
||||
@@ -158,6 +158,8 @@ export function Toolbar() {
|
||||
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating);
|
||||
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
|
||||
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
|
||||
const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly);
|
||||
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
|
||||
const similarScope = useGalleryStore((state) => state.similarScope);
|
||||
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
@@ -166,6 +168,7 @@ export function Toolbar() {
|
||||
const activeView = useGalleryStore((state) => state.activeView);
|
||||
|
||||
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
||||
const hasAnyFailedTagging = Object.values(mediaJobProgress).some((p) => p.tagging_failed > 0);
|
||||
|
||||
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState(search);
|
||||
@@ -441,12 +444,12 @@ export function Toolbar() {
|
||||
|
||||
{/* Filter row */}
|
||||
<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="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); 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 && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(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); setFailedTaggingOnly(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); setFailedTaggingOnly(false); }} />
|
||||
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
|
||||
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
|
||||
{hasAnyFailedEmbeddings ? (
|
||||
@@ -457,6 +460,14 @@ export function Toolbar() {
|
||||
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
|
||||
/>
|
||||
) : 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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+41
-4
@@ -309,6 +309,7 @@ interface GalleryState {
|
||||
favoritesOnly: boolean;
|
||||
minimumRating: number;
|
||||
failedEmbeddingsOnly: boolean;
|
||||
failedTaggingOnly: boolean;
|
||||
zoomPreset: ZoomPreset;
|
||||
selectedImage: ImageRecord | null;
|
||||
collectionTitle: string | null;
|
||||
@@ -403,6 +404,8 @@ interface GalleryState {
|
||||
setFavoritesOnly: (favoritesOnly: boolean) => void;
|
||||
setMinimumRating: (minimumRating: number) => void;
|
||||
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
|
||||
setFailedTaggingOnly: (failedTaggingOnly: boolean) => void;
|
||||
showFailedTagging: (folderId: number) => void;
|
||||
setZoomPreset: (zoomPreset: ZoomPreset) => void;
|
||||
openImage: (image: ImageRecord) => void;
|
||||
closeImage: () => void;
|
||||
@@ -593,6 +596,7 @@ function matchesFilters(
|
||||
favoritesOnly: boolean,
|
||||
minimumRating: number,
|
||||
failedEmbeddingsOnly: boolean,
|
||||
failedTaggingOnly: boolean,
|
||||
search: string,
|
||||
): boolean {
|
||||
const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId;
|
||||
@@ -600,7 +604,8 @@ function matchesFilters(
|
||||
const matchesFavorite = !favoritesOnly || image.favorite;
|
||||
const matchesRating = image.rating >= minimumRating;
|
||||
const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed";
|
||||
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesSearch(image, search);
|
||||
const matchesFailedTagging = !failedTaggingOnly || image.ai_tagger_error !== null;
|
||||
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesFailedTagging && matchesSearch(image, search);
|
||||
}
|
||||
|
||||
function compareNullableNumber(a: number | null, b: number | null): number {
|
||||
@@ -710,6 +715,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
favoritesOnly: false,
|
||||
minimumRating: 0,
|
||||
failedEmbeddingsOnly: false,
|
||||
failedTaggingOnly: false,
|
||||
zoomPreset: "comfortable",
|
||||
selectedImage: null,
|
||||
collectionTitle: null,
|
||||
@@ -879,7 +885,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
},
|
||||
|
||||
selectFolder: (folderId) => {
|
||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null });
|
||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
@@ -912,7 +918,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
},
|
||||
|
||||
loadImages: async (reset = false) => {
|
||||
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, activeView } = get();
|
||||
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, activeView } = get();
|
||||
const parsedSearch = parseSearchValue(search);
|
||||
const requestToken = ++galleryRequestToken;
|
||||
set({ loadingImages: true, imageLoadError: null });
|
||||
@@ -999,6 +1005,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
favorites_only: favoritesOnly,
|
||||
rating_min: minimumRating > 0 ? minimumRating : null,
|
||||
embedding_failed_only: failedEmbeddingsOnly,
|
||||
tagging_failed_only: failedTaggingOnly,
|
||||
sort,
|
||||
offset,
|
||||
limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE,
|
||||
@@ -1108,7 +1115,35 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
},
|
||||
|
||||
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
|
||||
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
set({ failedEmbeddingsOnly, failedTaggingOnly: failedEmbeddingsOnly ? false : get().failedTaggingOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
setFailedTaggingOnly: (failedTaggingOnly) => {
|
||||
set({ failedTaggingOnly, failedEmbeddingsOnly: failedTaggingOnly ? false : get().failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
showFailedTagging: (folderId) => {
|
||||
set({
|
||||
selectedFolderId: folderId,
|
||||
activeView: "gallery",
|
||||
search: "",
|
||||
mediaFilter: "all",
|
||||
favoritesOnly: false,
|
||||
minimumRating: 0,
|
||||
failedEmbeddingsOnly: false,
|
||||
failedTaggingOnly: true,
|
||||
images: [],
|
||||
loadedCount: 0,
|
||||
collectionTitle: null,
|
||||
similarSourceImageId: null,
|
||||
similarSourceFolderId: null,
|
||||
similarHasMore: false,
|
||||
similarFolderId: null,
|
||||
similarCrop: null,
|
||||
imageLoadError: null,
|
||||
});
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
@@ -2205,6 +2240,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
state.favoritesOnly,
|
||||
state.minimumRating,
|
||||
state.failedEmbeddingsOnly,
|
||||
state.failedTaggingOnly,
|
||||
state.search,
|
||||
),
|
||||
);
|
||||
@@ -2244,6 +2280,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
state.favoritesOnly,
|
||||
state.minimumRating,
|
||||
state.failedEmbeddingsOnly,
|
||||
state.failedTaggingOnly,
|
||||
state.search,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user