Compare commits

...

5 Commits

Author SHA1 Message Date
LyAhn 8eaa0bd8e8 feat: settings modal overhaul with General section and maintenance tools
- Remove Workers section (status-only, not settings)
- Remove Captioning coming-soon placeholder
- Add inline validation errors for threshold and batch size inputs
- Persist tagging queue scope and folder selection across app restarts
- Fix acceleration save error routing to correct UI location
- Add General section: open app data folder button
- Add Compact database card: live size metrics and VACUUM on demand
- Add Thumbnail cache card: orphaned thumbnail count, size, and cleanup
2026-06-09 01:09:40 +01:00
LyAhn 3707a35cc4 feat(settings): add orphaned thumbnail cleanup to General section
Scans the thumbnails directory and cross-references against thumbnail_path
values in the images table, deleting any files not linked to an indexed image.
Surfaces count and reclaimable MB in the General section alongside the
existing Compact database card.
2026-06-09 01:09:29 +01:00
LyAhn d1eb75a4f5 feat(settings): add compact database card to General section
Adds get_database_info and vacuum_database Tauri commands. The General
section now shows current DB size and reclaimable space on load, with a
Compact now button that runs PRAGMA wal_checkpoint(FULL) + VACUUM and
reports how many MB were freed. Button disables automatically when the
database is already compact (< 0.5 MB reclaimable).
2026-06-09 00:40:05 +01:00
LyAhn fbdd43d9d9 fix(settings): correct invoke payload keys and error handler routing
- folder_ids: use snake_case in both set_tagging_queue_folder_ids invoke
  calls (toggleTaggingQueueFolder and setTaggingQueueFolderIds) to match
  the Rust SetTaggingQueueFolderIdsParams serde field name
- threshold save error: route setTaggerThreshold() catch to
  setTaggerThresholdError instead of setTaggerQueueStatus so the error
  surfaces next to the threshold input, not the queue targets card
2026-06-09 00:22:30 +01:00
LyAhn 33fb3c6c77 feat(settings): overhaul Settings modal with improvements and General section
- Remove Workers section (read-only status, belongs in background tasks panel)
- Remove Captioning coming-soon placeholder
- Add General section with Open data folder button (tauri-plugin-opener)
- Persist queue scope and folder selection across sessions via settings files
- Show inline validation errors on threshold/batch size inputs instead of silent revert
- Fix acceleration save errors appearing in wrong card
- Disable folder selection controls when target scope is All media
2026-06-09 00:14:49 +01:00
4 changed files with 503 additions and 47 deletions
+230
View File
@@ -1614,3 +1614,233 @@ pub async fn remove_tag(db: State<'_, DbState>, params: RemoveTagParams) -> Resu
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string()) db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string())
} }
// ---------------------------------------------------------------------------
// Queue scope / folder-id persistence
// ---------------------------------------------------------------------------
const TAGGING_QUEUE_SCOPE_FILE: &str = "settings/tagging_queue_scope.txt";
const TAGGING_QUEUE_FOLDER_IDS_FILE: &str = "settings/tagging_queue_folder_ids.txt";
#[derive(Deserialize)]
pub struct SetTaggingQueueScopeParams {
pub scope: String,
}
#[derive(Deserialize)]
pub struct SetTaggingQueueFolderIdsParams {
pub folder_ids: Vec<i64>,
}
#[tauri::command]
pub async fn get_tagging_queue_scope(app: AppHandle) -> Result<String, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE);
let value = std::fs::read_to_string(path).unwrap_or_default();
Ok(if value.trim() == "selected" { "selected".to_string() } else { "all".to_string() })
}
#[tauri::command]
pub async fn set_tagging_queue_scope(
app: AppHandle,
params: SetTaggingQueueScopeParams,
) -> Result<String, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let value = if params.scope == "selected" { "selected" } else { "all" };
std::fs::write(path, value).map_err(|e| e.to_string())?;
Ok(value.to_string())
}
#[tauri::command]
pub async fn get_tagging_queue_folder_ids(app: AppHandle) -> Result<Vec<i64>, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let path = app_dir.join(TAGGING_QUEUE_FOLDER_IDS_FILE);
let Ok(content) = std::fs::read_to_string(path) else {
return Ok(vec![]);
};
let ids = content
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.collect();
Ok(ids)
}
#[tauri::command]
pub async fn set_tagging_queue_folder_ids(
app: AppHandle,
params: SetTaggingQueueFolderIdsParams,
) -> Result<(), String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let path = app_dir.join(TAGGING_QUEUE_FOLDER_IDS_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let content: Vec<String> = params.folder_ids.iter().map(|id| id.to_string()).collect();
std::fs::write(path, content.join(",")).map_err(|e| e.to_string())?;
Ok(())
}
// ---------------------------------------------------------------------------
// App data folder
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn open_app_data_folder(app: AppHandle) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
app.opener()
.open_path(app_dir.to_string_lossy().as_ref(), None::<&str>)
.map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Database maintenance
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct DatabaseInfo {
pub size_mb: f64,
pub reclaimable_mb: f64,
}
#[derive(Serialize)]
pub struct VacuumResult {
pub before_mb: f64,
pub after_mb: f64,
pub freed_mb: f64,
}
#[tauri::command]
pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result<DatabaseInfo, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let db_path = app_dir.join("gallery.db");
let size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
let conn = db.get().map_err(|e| e.to_string())?;
let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0)).map_err(|e| e.to_string())?;
let freelist_count: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0)).map_err(|e| e.to_string())?;
Ok(DatabaseInfo {
size_mb: size_bytes as f64 / 1_048_576.0,
reclaimable_mb: (freelist_count * page_size) as f64 / 1_048_576.0,
})
}
#[tauri::command]
pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result<VacuumResult, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let db_path = app_dir.join("gallery.db");
let before_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
let conn = db.get().map_err(|e| e.to_string())?;
conn.execute_batch("PRAGMA wal_checkpoint(FULL); VACUUM;").map_err(|e| e.to_string())?;
drop(conn);
let after_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
Ok(VacuumResult {
before_mb: before_bytes as f64 / 1_048_576.0,
after_mb: after_bytes as f64 / 1_048_576.0,
freed_mb: before_bytes.saturating_sub(after_bytes) as f64 / 1_048_576.0,
})
}
#[derive(serde::Serialize)]
pub struct OrphanedThumbnailsInfo {
pub count: u64,
pub size_mb: f64,
}
#[derive(serde::Serialize)]
pub struct CleanupOrphanedThumbnailsResult {
pub deleted_count: u64,
pub freed_mb: f64,
}
fn collect_db_thumbnail_filenames(conn: &rusqlite::Connection) -> Result<std::collections::HashSet<String>, String> {
let mut stmt = conn
.prepare("SELECT thumbnail_path FROM images WHERE thumbnail_path IS NOT NULL")
.map_err(|e| e.to_string())?;
let set = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.filter_map(|p| {
std::path::Path::new(&p)
.file_name()
.and_then(|f| f.to_str())
.map(|s| s.to_owned())
})
.collect();
Ok(set)
}
#[tauri::command]
pub async fn get_orphaned_thumbnails_info(
app: AppHandle,
db: State<'_, DbState>,
) -> Result<OrphanedThumbnailsInfo, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let thumb_dir = app_dir.join("thumbnails");
let conn = db.get().map_err(|e| e.to_string())?;
let db_filenames = collect_db_thumbnail_filenames(&conn)?;
drop(conn);
let mut count = 0u64;
let mut size_bytes = 0u64;
if thumb_dir.exists() {
for entry in std::fs::read_dir(&thumb_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let fname = entry.file_name();
if !db_filenames.contains(fname.to_string_lossy().as_ref()) {
size_bytes += entry.metadata().map(|m| m.len()).unwrap_or(0);
count += 1;
}
}
}
Ok(OrphanedThumbnailsInfo {
count,
size_mb: size_bytes as f64 / 1_048_576.0,
})
}
#[tauri::command]
pub async fn cleanup_orphaned_thumbnails(
app: AppHandle,
db: State<'_, DbState>,
) -> Result<CleanupOrphanedThumbnailsResult, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let thumb_dir = app_dir.join("thumbnails");
let conn = db.get().map_err(|e| e.to_string())?;
let db_filenames = collect_db_thumbnail_filenames(&conn)?;
drop(conn);
let mut deleted_count = 0u64;
let mut freed_bytes = 0u64;
if thumb_dir.exists() {
for entry in std::fs::read_dir(&thumb_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let fname = entry.file_name();
if !db_filenames.contains(fname.to_string_lossy().as_ref()) {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
if std::fs::remove_file(entry.path()).is_ok() {
deleted_count += 1;
freed_bytes += size;
}
}
}
}
Ok(CleanupOrphanedThumbnailsResult {
deleted_count,
freed_mb: freed_bytes as f64 / 1_048_576.0,
})
}
+9
View File
@@ -139,6 +139,15 @@ pub fn run() {
commands::delete_images_from_disk, commands::delete_images_from_disk,
commands::rename_folder, commands::rename_folder,
commands::update_folder_path, commands::update_folder_path,
commands::get_tagging_queue_scope,
commands::set_tagging_queue_scope,
commands::get_tagging_queue_folder_ids,
commands::set_tagging_queue_folder_ids,
commands::open_app_data_folder,
commands::get_database_info,
commands::vacuum_database,
commands::get_orphaned_thumbnails_info,
commands::cleanup_orphaned_thumbnails,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+200 -46
View File
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store"; import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
type SettingsSection = "workspace" | "workers"; type SettingsSection = "workspace" | "general";
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [ const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" }, { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
{ id: "workers", label: "Workers", detail: "Queue activity and background processing" }, { id: "general", label: "General", detail: "App data and diagnostics" },
]; ];
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) { function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
@@ -109,19 +109,35 @@ export function SettingsModal() {
const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null); const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false); const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null);
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null); const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null);
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false); const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
const [taggerBatchSizeError, setTaggerBatchSizeError] = useState<string | null>(null);
const [openingDataFolder, setOpeningDataFolder] = useState(false);
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null);
const [vacuuming, setVacuuming] = useState(false);
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const settingsOpen = useGalleryStore((state) => state.settingsOpen); const settingsOpen = useGalleryStore((state) => state.settingsOpen);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen); const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope); const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope);
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds); const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds);
const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope);
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope); const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder); const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds); const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing); const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress); const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
@@ -145,6 +161,11 @@ export function SettingsModal() {
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders); const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
useEffect(() => { useEffect(() => {
if (!settingsOpen) return; if (!settingsOpen) return;
@@ -152,24 +173,37 @@ export function SettingsModal() {
void loadTaggerAcceleration(); void loadTaggerAcceleration();
void loadTaggerThreshold(); void loadTaggerThreshold();
void loadTaggerBatchSize(); void loadTaggerBatchSize();
void loadTaggingQueueScope();
void loadTaggingQueueFolderIds();
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsOpen(false); if (event.key === "Escape") setSettingsOpen(false);
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, setSettingsOpen]); }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
useEffect(() => {
if (!settingsOpen || activeSection !== "general") return;
setVacuumResult(null);
setThumbnailCleanupResult(null);
void getDatabaseInfo().then(setDbInfo).catch(() => {});
void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {});
}, [settingsOpen, activeSection, getDatabaseInfo, getOrphanedThumbnailsInfo]);
// Clean up error timers on unmount
useEffect(() => {
return () => {
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
};
}, []);
const selectedFolders = useMemo( const selectedFolders = useMemo(
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)), () => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
[folders, taggingQueueFolderIds], [folders, taggingQueueFolderIds],
); );
const totalQueuedJobs = useMemo(
() => Object.values(mediaJobProgress).reduce((sum, progress) => sum + (progress?.tagging_pending ?? 0), 0),
[mediaJobProgress],
);
if (!settingsOpen) return null; if (!settingsOpen) return null;
const taggerReady = taggerModelStatus?.ready ?? false; const taggerReady = taggerModelStatus?.ready ?? false;
@@ -268,8 +302,8 @@ export function SettingsModal() {
<main className="flex min-w-0 flex-1 flex-col"> <main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6"> <div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6">
<div> <div>
<p className="text-sm font-medium text-white">{activeSection === "workspace" ? "AI Workspace" : "Workers"}</p> <p className="text-sm font-medium text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</p>
<p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "Background processing status"}</p> <p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
</div> </div>
<button <button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
@@ -344,8 +378,9 @@ export function SettingsModal() {
current={taggerAcceleration} current={taggerAcceleration}
onSelect={(nextAcceleration) => { onSelect={(nextAcceleration) => {
setTaggerAccelerationSaving(true); setTaggerAccelerationSaving(true);
setTaggerAccelerationError(null);
void setTaggerAcceleration(nextAcceleration) void setTaggerAcceleration(nextAcceleration)
.catch((error) => setTaggerQueueStatus(String(error))) .catch((error: unknown) => setTaggerAccelerationError(String(error)))
.finally(() => setTaggerAccelerationSaving(false)); .finally(() => setTaggerAccelerationSaving(false));
}} }}
> >
@@ -353,7 +388,11 @@ export function SettingsModal() {
</TaggerAccelerationButton> </TaggerAccelerationButton>
))} ))}
</div> </div>
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p> {taggerAccelerationError ? (
<p className="text-[11px] text-amber-300">{taggerAccelerationError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
)}
</div> </div>
</SettingsRow> </SettingsRow>
@@ -370,19 +409,27 @@ export function SettingsModal() {
onBlur={() => { onBlur={() => {
const value = parseFloat(thresholdDisplay); const value = parseFloat(thresholdDisplay);
if (!isNaN(value) && value >= 0.05 && value <= 0.99) { if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdError(null);
setTaggerThresholdSaving(true); setTaggerThresholdSaving(true);
void setTaggerThreshold(value) void setTaggerThreshold(value)
.catch((error) => setTaggerQueueStatus(String(error))) .catch((error: unknown) => setTaggerThresholdError(String(error)))
.finally(() => { .finally(() => {
setTaggerThresholdDraft(null); setTaggerThresholdDraft(null);
setTaggerThresholdSaving(false); setTaggerThresholdSaving(false);
}); });
} else { } else {
setTaggerThresholdDraft(null); setTaggerThresholdDraft(null);
setTaggerThresholdError("Must be 0.05 0.99");
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
} }
}} }}
/> />
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p> {taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
)}
</div> </div>
</SettingsRow> </SettingsRow>
@@ -399,6 +446,7 @@ export function SettingsModal() {
onBlur={() => { onBlur={() => {
const value = parseInt(batchSizeDisplay, 10); const value = parseInt(batchSizeDisplay, 10);
if (!isNaN(value) && value >= 1 && value <= 100) { if (!isNaN(value) && value >= 1 && value <= 100) {
setTaggerBatchSizeError(null);
setTaggerBatchSizeSaving(true); setTaggerBatchSizeSaving(true);
void setTaggerBatchSize(value) void setTaggerBatchSize(value)
.catch((error: unknown) => setTaggerQueueStatus(String(error))) .catch((error: unknown) => setTaggerQueueStatus(String(error)))
@@ -408,10 +456,17 @@ export function SettingsModal() {
}); });
} else { } else {
setTaggerBatchSizeDraft(null); setTaggerBatchSizeDraft(null);
setTaggerBatchSizeError("Must be 1 100");
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
} }
}} }}
/> />
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p> {taggerBatchSizeError ? (
<p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
)}
</div> </div>
</SettingsRow> </SettingsRow>
@@ -453,16 +508,16 @@ export function SettingsModal() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40" className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))} onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={folders.length === 0} disabled={taggingQueueScope === "all" || folders.length === 0}
> >
Select all Select all
</button> </button>
<button <button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40" className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setTaggingQueueFolderIds([])} onClick={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueFolderIds.length === 0} disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
> >
Clear Clear
</button> </button>
@@ -477,12 +532,13 @@ export function SettingsModal() {
<button <button
key={folder.id} key={folder.id}
type="button" type="button"
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors ${ className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed ${
active active
? "border-emerald-400/30 bg-emerald-500/10 text-white" ? "border-emerald-400/30 bg-emerald-500/10 text-white"
: "border-white/[0.07] bg-black/20 text-gray-300 hover:border-white/15 hover:bg-white/[0.04]" : "border-white/[0.07] bg-black/20 text-gray-300 hover:border-white/15 hover:bg-white/[0.04]"
}`} }`}
onClick={() => toggleTaggingQueueFolder(folder.id)} onClick={() => toggleTaggingQueueFolder(folder.id)}
disabled={taggingQueueScope === "all"}
> >
<div> <div>
<p className="text-sm font-medium">{folder.name}</p> <p className="text-sm font-medium">{folder.name}</p>
@@ -520,41 +576,139 @@ export function SettingsModal() {
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null} {taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsCard> </SettingsCard>
<SettingsCard title="Captioning">
<div className="rounded-xl border border-dashed border-white/[0.09] bg-black/20 px-4 py-4">
<p className="text-sm font-medium text-white/50">Coming soon</p>
</div>
</SettingsCard>
</SectionShell> </SectionShell>
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
<SectionShell <SectionShell
eyebrow="Workers" eyebrow="General"
title="Background processing" title="App data"
description="Access the folder where Phokus stores its database, thumbnails, AI models, and settings."
> >
<SettingsCard title="Queue summary" description="Live totals across all folder workers."> <SettingsCard title="Storage location">
<div className="grid gap-3 sm:grid-cols-3"> <div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <p className="text-sm text-gray-400">Open the app data folder in Explorer to inspect or back up files.</p>
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Tagging queued</p> <button
<p className="mt-2 text-2xl font-semibold text-white">{totalQueuedJobs.toLocaleString()}</p> className="shrink-0 rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => {
setOpeningDataFolder(true);
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
}}
disabled={openingDataFolder}
>
{openingDataFolder ? "Opening..." : "Open data folder"}
</button>
</div>
</SettingsCard>
<SettingsCard
title="Compact database"
description="Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time."
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Database size</p>
<p className="mt-2 text-2xl font-semibold text-white">
{vacuumResult
? `${vacuumResult.after_mb.toFixed(1)} MB`
: dbInfo
? `${dbInfo.size_mb.toFixed(1)} MB`
: "—"}
</p>
</div>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Reclaimable</p>
<p className={`mt-2 text-2xl font-semibold ${vacuumResult ? "text-emerald-300" : "text-white"}`}>
{vacuumResult
? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
: dbInfo
? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
: "—"}
</p>
</div>
</div> </div>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Selected folders</p> <p className="text-sm text-gray-400">
<p className="mt-2 text-2xl font-semibold text-white">{selectedFolders.length.toLocaleString()}</p> {vacuumResult
</div> ? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> : dbInfo && dbInfo.reclaimable_mb < 0.5
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Library folders</p> ? "Database is already compact."
<p className="mt-2 text-2xl font-semibold text-white">{folders.length.toLocaleString()}</p> : "Run this after removing folders or bulk-deleting images."}
</p>
<button
className="shrink-0 rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => {
setVacuuming(true);
setVacuumResult(null);
void vacuumDatabase()
.then((result) => {
setVacuumResult(result);
setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
})
.catch(() => {})
.finally(() => setVacuuming(false));
}}
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5 && vacuumResult === null)}
>
{vacuuming ? "Compacting..." : "Compact now"}
</button>
</div> </div>
</div> </div>
</SettingsCard> </SettingsCard>
<SettingsCard title="Worker controls" description="Pause, resume, retry, and per-folder failure details are available in the background tasks panel."> <SettingsCard
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4"> title="Thumbnail cache"
<p className="text-sm text-gray-400">Workers are running in the background.</p> description="Thumbnails left behind when folders or images are removed. Safe to delete — they will be regenerated if the original files are re-indexed."
<StatusPill tone="ready">Live</StatusPill> >
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Orphaned files</p>
<p className="mt-2 text-2xl font-semibold text-white">
{thumbnailCleanupResult
? thumbnailCleanupResult.deleted_count.toLocaleString()
: thumbnailInfo
? thumbnailInfo.count.toLocaleString()
: "—"}
</p>
</div>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Reclaimable</p>
<p className={`mt-2 text-2xl font-semibold ${thumbnailCleanupResult ? "text-emerald-300" : "text-white"}`}>
{thumbnailCleanupResult
? `${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed`
: thumbnailInfo
? `${thumbnailInfo.size_mb.toFixed(1)} MB`
: "—"}
</p>
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-sm text-gray-400">
{thumbnailCleanupResult
? `Removed ${thumbnailCleanupResult.deleted_count.toLocaleString()} orphaned thumbnail${thumbnailCleanupResult.deleted_count === 1 ? "" : "s"}.`
: thumbnailInfo && thumbnailInfo.count === 0
? "No orphaned thumbnails found."
: "Remove thumbnails no longer associated with any indexed image."}
</p>
<button
className="shrink-0 rounded-lg bg-white/[0.07] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/[0.11] disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
setCleaningThumbnails(true);
cleanupOrphanedThumbnails()
.then((result) => {
setThumbnailCleanupResult(result);
setThumbnailInfo(null);
})
.catch(() => {})
.finally(() => setCleaningThumbnails(false));
}}
disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
>
{cleaningThumbnails ? "Cleaning..." : "Clean up"}
</button>
</div>
</div> </div>
</SettingsCard> </SettingsCard>
</SectionShell> </SectionShell>
+64 -1
View File
@@ -71,6 +71,27 @@ export interface ImageTag {
created_at: string; created_at: string;
} }
export interface DatabaseInfo {
size_mb: number;
reclaimable_mb: number;
}
export interface VacuumResult {
before_mb: number;
after_mb: number;
freed_mb: number;
}
export interface OrphanedThumbnailsInfo {
count: number;
size_mb: number;
}
export interface CleanupOrphanedThumbnailsResult {
deleted_count: number;
freed_mb: number;
}
export interface TaggerModelStatus { export interface TaggerModelStatus {
model_id: string; model_id: string;
model_name: string; model_name: string;
@@ -334,9 +355,16 @@ interface GalleryState {
setCaptionDetail: (detail: CaptionDetail) => Promise<void>; setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
setAiCaptionsEnabled: (enabled: boolean) => void; setAiCaptionsEnabled: (enabled: boolean) => void;
setSettingsOpen: (open: boolean) => void; setSettingsOpen: (open: boolean) => void;
loadTaggingQueueScope: () => Promise<void>;
setTaggingQueueScope: (scope: TaggingQueueScope) => void; setTaggingQueueScope: (scope: TaggingQueueScope) => void;
loadTaggingQueueFolderIds: () => Promise<void>;
toggleTaggingQueueFolder: (folderId: number) => void; toggleTaggingQueueFolder: (folderId: number) => void;
setTaggingQueueFolderIds: (folderIds: number[]) => void; setTaggingQueueFolderIds: (folderIds: number[]) => void;
openAppDataFolder: () => Promise<void>;
getDatabaseInfo: () => Promise<DatabaseInfo>;
vacuumDatabase: () => Promise<VacuumResult>;
getOrphanedThumbnailsInfo: () => Promise<OrphanedThumbnailsInfo>;
cleanupOrphanedThumbnails: () => Promise<CleanupOrphanedThumbnailsResult>;
retryFailedEmbeddings: (folderId: number) => Promise<void>; retryFailedEmbeddings: (folderId: number) => Promise<void>;
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>; updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
setCacheDir: (dir: string) => void; setCacheDir: (dir: string) => void;
@@ -1299,6 +1327,15 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setSettingsOpen: (settingsOpen) => set({ settingsOpen }), setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
loadTaggingQueueScope: async () => {
try {
const scope = await invoke<TaggingQueueScope>("get_tagging_queue_scope");
set({ taggingQueueScope: scope });
} catch {
// silently fall back to in-memory default
}
},
setTaggingQueueScope: (taggingQueueScope) => { setTaggingQueueScope: (taggingQueueScope) => {
set((state) => ({ set((state) => ({
taggingQueueScope, taggingQueueScope,
@@ -1307,6 +1344,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
? [state.folders[0].id] ? [state.folders[0].id]
: state.taggingQueueFolderIds, : state.taggingQueueFolderIds,
})); }));
void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {});
},
loadTaggingQueueFolderIds: async () => {
try {
const folderIds = await invoke<number[]>("get_tagging_queue_folder_ids");
set({ taggingQueueFolderIds: folderIds });
} catch {
// silently fall back to in-memory default
}
}, },
toggleTaggingQueueFolder: (folderId) => { toggleTaggingQueueFolder: (folderId) => {
@@ -1314,11 +1361,27 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const next = state.taggingQueueFolderIds.includes(folderId) const next = state.taggingQueueFolderIds.includes(folderId)
? state.taggingQueueFolderIds.filter((id) => id !== folderId) ? state.taggingQueueFolderIds.filter((id) => id !== folderId)
: [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b); : [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b);
void invoke("set_tagging_queue_folder_ids", { folder_ids: next }).catch(() => {});
return { taggingQueueFolderIds: next }; return { taggingQueueFolderIds: next };
}); });
}, },
setTaggingQueueFolderIds: (taggingQueueFolderIds) => set({ taggingQueueFolderIds }), setTaggingQueueFolderIds: (taggingQueueFolderIds) => {
set({ taggingQueueFolderIds });
void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {});
},
openAppDataFolder: async () => {
await invoke("open_app_data_folder");
},
getDatabaseInfo: () => invoke<DatabaseInfo>("get_database_info"),
vacuumDatabase: () => invoke<VacuumResult>("vacuum_database"),
getOrphanedThumbnailsInfo: () => invoke<OrphanedThumbnailsInfo>("get_orphaned_thumbnails_info"),
cleanupOrphanedThumbnails: () => invoke<CleanupOrphanedThumbnailsResult>("cleanup_orphaned_thumbnails"),
loadTaggerModelStatus: async () => { loadTaggerModelStatus: async () => {
try { try {