Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d84c74e241 | |||
| 4cd3bbd4fd | |||
| 86ce7bc8e2 | |||
| b02bf1da2b | |||
| cd7dd89f00 |
@@ -4,16 +4,30 @@ A local-first desktop media library for browsing, filtering, and curating image
|
||||
|
||||
## Features
|
||||
|
||||
### Library
|
||||
- Add and remove media folders; background indexing with live progress
|
||||
- **Live file tracking** — a filesystem watcher keeps the library in sync as files are added, changed, or removed; renames and moves are handled in place, preserving thumbnails and embeddings
|
||||
- Browse all media or filter by folder, type (image/video), favorites, or star rating
|
||||
- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`)
|
||||
- **Similar image search** — find visually similar media by image or selected region
|
||||
- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold control
|
||||
- **Explore view** — visual cluster map and tag cloud for browsing by theme
|
||||
- **Duplicate finder** — scan for exact duplicates by file hash with bulk delete
|
||||
- Lightbox preview with keyboard navigation, inline tag editing, and rating controls
|
||||
- Sort by date, name, size, rating, or duration
|
||||
- Sort by date added, date taken (EXIF), name, size, rating, or duration
|
||||
- Grid density controls (compact / comfortable / detail)
|
||||
- Desktop notifications, batched per folder, with per-folder mute and a global pause
|
||||
|
||||
### Search & discovery
|
||||
- **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`)
|
||||
- **Similar image search** — find visually similar media by image or a selected region
|
||||
- **Explore view** — visual cluster map and tag cloud for browsing by theme
|
||||
- **Timeline view** — media grouped chronologically by capture date (EXIF)
|
||||
- **Duplicate finder** — three-phase exact-duplicate scan (size → sample hash → full hash) with live progress and bulk delete
|
||||
- Explore, Timeline, and Duplicates are folder-scopable directly from their headers — no need to bounce through the sidebar
|
||||
|
||||
### Viewing & curation
|
||||
- Lightbox with keyboard navigation, zoom, inline tag editing, and rating controls
|
||||
- **Custom video player** — immersive edge-to-edge playback with scrubbing, volume, playback speed, loop, and fullscreen; auto-hiding controls and full keyboard support
|
||||
- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold, batch size, and per-folder queue targeting
|
||||
|
||||
### Maintenance
|
||||
- Database compaction and orphaned-thumbnail cleanup from Settings, with live size/reclaimable stats
|
||||
- Per-folder pausing of background work (thumbnails, metadata, embeddings, tagging)
|
||||
|
||||
## Supported formats
|
||||
|
||||
@@ -26,9 +40,10 @@ A local-first desktop media library for browsing, filtering, and curating image
|
||||
|
||||
- Tauri 2 + Rust backend
|
||||
- React 19 + TypeScript + Zustand
|
||||
- SQLite + `sqlite-vec` (vector search)
|
||||
- SQLite + `sqlite-vec` (vector search) + HNSW index
|
||||
- ONNX Runtime (`ort`) for AI tagging
|
||||
- Candle (Rust ML) for visual embeddings
|
||||
- Candle (Rust ML) for CLIP visual embeddings
|
||||
- mozjpeg for fast scaled JPEG decoding
|
||||
- FFmpeg sidecar for video thumbnails and metadata
|
||||
- Vite + Tailwind CSS v4
|
||||
|
||||
@@ -47,12 +62,16 @@ pnpm dev:vite
|
||||
|
||||
# Production build
|
||||
pnpm build:app
|
||||
|
||||
# Type-check the frontend
|
||||
pnpm build:vite
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. Add a folder from the sidebar — the Rust indexer walks it recursively.
|
||||
2. Supported files are written to SQLite with metadata (path, dimensions, media type, etc.).
|
||||
3. Background workers generate thumbnails, compute visual embeddings, and run AI tagging.
|
||||
2. Supported files are written to SQLite with metadata (path, dimensions, media type, EXIF capture date, etc.).
|
||||
3. Background workers process the queue as a strict priority pipeline — thumbnails first, then video metadata, then visual embeddings, then AI tags — so each stage runs at full speed instead of competing for CPU, disk, and the database.
|
||||
4. Progress events stream back to the UI while the gallery updates incrementally.
|
||||
5. Embeddings power semantic search and the similar images feature via an HNSW index.
|
||||
5. A filesystem watcher picks up later changes (new files, edits, deletions, renames) and keeps the index current without rescans.
|
||||
6. Embeddings power semantic search and the similar-images feature via an HNSW index.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { DuplicateGroup, useGalleryStore } from "../store";
|
||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
||||
@@ -188,6 +189,7 @@ export function DuplicateFinder() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderScopeDropdown />
|
||||
{/* Batch select — only shown when there are groups and nothing is selected yet */}
|
||||
{hasResults && selectedCount === 0 && !deleting && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useGalleryStore } from "../store";
|
||||
|
||||
/**
|
||||
* In-view folder scope picker for feature views (Timeline / Explore /
|
||||
* Duplicates). Changes the scope via setViewFolderScope, which keeps the
|
||||
* current view active — unlike sidebar folder clicks, which jump to Gallery.
|
||||
*/
|
||||
export function FolderScopeDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const folders = useGalleryStore((state) => state.folders);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope);
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", close);
|
||||
return () => window.removeEventListener("pointerdown", close);
|
||||
}, []);
|
||||
|
||||
const currentLabel =
|
||||
selectedFolderId === null
|
||||
? "All Media"
|
||||
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media";
|
||||
|
||||
const select = (folderId: number | null) => {
|
||||
setViewFolderScope(folderId);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
|
||||
open
|
||||
? "border-white/15 bg-white/8 text-white"
|
||||
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
|
||||
}`}
|
||||
title="Change folder scope"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="truncate">{currentLabel}</span>
|
||||
<svg
|
||||
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur">
|
||||
<button
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
selectedFolderId === null ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
|
||||
}`}
|
||||
onClick={() => select(null)}
|
||||
>
|
||||
All Media
|
||||
{selectedFolderId === null ? (
|
||||
<svg className="h-3.5 w-3.5 shrink-0 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : null}
|
||||
</button>
|
||||
{folders.map((folder) => {
|
||||
const active = selectedFolderId === folder.id;
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
active ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
|
||||
}`}
|
||||
onClick={() => select(folder.id)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{folder.name}</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()}</span>
|
||||
{active ? (
|
||||
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { useGalleryStore, ImageTag, AiRating } from "../store";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -223,8 +224,9 @@ export function Lightbox() {
|
||||
}
|
||||
}
|
||||
if (regionSelectMode) return; // block nav keys during selection
|
||||
if (event.key === "ArrowLeft") goPrev();
|
||||
if (event.key === "ArrowRight") goNext();
|
||||
// Shift+arrows are reserved for video seeking (handled by VideoPlayer)
|
||||
if (event.key === "ArrowLeft" && !event.shiftKey) goPrev();
|
||||
if (event.key === "ArrowRight" && !event.shiftKey) goNext();
|
||||
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
|
||||
if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25));
|
||||
};
|
||||
@@ -365,19 +367,18 @@ export function Lightbox() {
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={selectedImage.id}
|
||||
className="flex items-center justify-center"
|
||||
className={
|
||||
selectedImage.media_kind === "video"
|
||||
? "absolute inset-0"
|
||||
: "flex items-center justify-center"
|
||||
}
|
||||
initial={{ opacity: 0.3, scale: 0.985 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0.3, scale: 0.985 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
{selectedImage.media_kind === "video" ? (
|
||||
<video
|
||||
src={convertFileSrc(selectedImage.path)}
|
||||
controls
|
||||
className="max-h-full max-w-full rounded-2xl shadow-2xl"
|
||||
style={{ maxHeight: "calc(100vh - 10rem)" }}
|
||||
/>
|
||||
<VideoPlayer src={convertFileSrc(selectedImage.path)} />
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
|
||||
+175
-190
@@ -16,49 +16,59 @@ function StatusPill({ children, tone }: { children: React.ReactNode; tone: "read
|
||||
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
|
||||
: "border-white/10 bg-white/[0.04] text-gray-500";
|
||||
|
||||
return <span className={`inline-flex rounded-md border px-2 py-1 text-[11px] font-medium ${className}`}>{children}</span>;
|
||||
return <span className={`inline-flex rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function SectionShell({ eyebrow, title, description, children }: {
|
||||
eyebrow: string;
|
||||
function SettingsGroup({ title, description, children }: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p>
|
||||
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3>
|
||||
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-gray-500">{description}</p> : null}
|
||||
<div className="mt-5 space-y-4">{children}</div>
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">{title}</h4>
|
||||
{description ? <p className="mt-1 text-xs leading-relaxed text-gray-600">{description}</p> : null}
|
||||
<div className="mt-1 divide-y divide-white/[0.05]">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||
function SettingsItem({ label, description, children, vertical = false }: {
|
||||
label: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
vertical?: boolean;
|
||||
}) {
|
||||
if (vertical) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5">
|
||||
<div className="flex flex-col gap-1 border-b border-white/[0.07] pb-4">
|
||||
<p className="text-sm font-medium text-white">{title}</p>
|
||||
{description ? <p className="text-xs leading-relaxed text-gray-500">{description}</p> : null}
|
||||
</div>
|
||||
<div className="pt-4">{children}</div>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-white">{label}</p>
|
||||
{description ? <div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div> : null}
|
||||
{children ? <div className="mt-3">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-5 border-b border-white/[0.07] py-4 last:border-b-0 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white">{title}</p>
|
||||
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
|
||||
<p className="text-sm text-white">{label}</p>
|
||||
{description ? <div className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">{description}</div> : null}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-gray-600">{label}</span>
|
||||
<span className={`text-sm font-semibold tabular-nums ${accent ? "text-emerald-300" : "text-white"}`}>{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeButton({ scope, current, onSelect, children }: {
|
||||
scope: TaggingQueueScope;
|
||||
current: TaggingQueueScope;
|
||||
@@ -72,7 +82,7 @@ function ScopeButton({ scope, current, onSelect, children }: {
|
||||
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||
active
|
||||
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
|
||||
: "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
|
||||
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
|
||||
}`}
|
||||
onClick={() => onSelect(scope)}
|
||||
>
|
||||
@@ -94,7 +104,7 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
|
||||
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||
active
|
||||
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
|
||||
: "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
|
||||
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
|
||||
}`}
|
||||
onClick={() => onSelect(acceleration)}
|
||||
>
|
||||
@@ -277,7 +287,7 @@ export function SettingsModal() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
|
||||
<div
|
||||
className="flex h-[min(760px,calc(100vh-56px))] w-full max-w-5xl overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
|
||||
className="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
|
||||
@@ -301,14 +311,8 @@ export function SettingsModal() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<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>
|
||||
<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" : "App data and diagnostics"}</p>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||
className="absolute right-4 top-4 z-10 rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
title="Close settings"
|
||||
>
|
||||
@@ -316,38 +320,29 @@ export function SettingsModal() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-7 py-6">
|
||||
<main className="min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="px-10 py-8">
|
||||
<h3 className="text-lg font-semibold text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</h3>
|
||||
<p className="mt-1 text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
|
||||
|
||||
{activeSection === "workspace" ? (
|
||||
<div className="space-y-8">
|
||||
<SectionShell
|
||||
eyebrow="AI Workspace"
|
||||
title="Tagging"
|
||||
>
|
||||
<SettingsCard title="Tagging Models">
|
||||
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-white">WD SwinV2 Tagger v3</p>
|
||||
<div className="mt-8 space-y-9">
|
||||
<SettingsGroup title="Model">
|
||||
<SettingsItem
|
||||
label={
|
||||
<>
|
||||
WD SwinV2 Tagger v3{" "}
|
||||
<span className="ml-1.5 align-middle">
|
||||
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
|
||||
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
|
||||
</StatusPill>
|
||||
</div>
|
||||
<p className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">
|
||||
Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<button
|
||||
className="relative overflow-hidden 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={() => void prepareTaggerModel()}
|
||||
disabled={taggerModelPreparing || taggerReady}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds."
|
||||
>
|
||||
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
|
||||
<span className="relative">{taggerDownloadLabel}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{taggerReady ? (
|
||||
<>
|
||||
<button
|
||||
@@ -365,14 +360,22 @@ export function SettingsModal() {
|
||||
Delete model files
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="relative overflow-hidden 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={() => void prepareTaggerModel()}
|
||||
disabled={taggerModelPreparing}
|
||||
>
|
||||
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
|
||||
<span className="relative">{taggerDownloadLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
<div className="mt-4 space-y-4 border-t border-white/[0.07] pt-4">
|
||||
<SettingsRow title="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
|
||||
<SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<div className="flex rounded-lg border border-white/[0.07] p-0.5">
|
||||
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
|
||||
<TaggerAccelerationButton
|
||||
key={acceleration}
|
||||
@@ -396,10 +399,10 @@ export function SettingsModal() {
|
||||
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsRow title="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<SettingsItem label="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min="0.05"
|
||||
@@ -433,10 +436,10 @@ export function SettingsModal() {
|
||||
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsRow title="Tagging batch size" description="Number of images processed concurrently during tag generation.">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<SettingsItem label="Tagging batch size" description="Number of images processed concurrently during tag generation.">
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -470,42 +473,40 @@ export function SettingsModal() {
|
||||
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem label="Model location" vertical>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-400">Model location</p>
|
||||
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
|
||||
<p className="break-all font-mono text-xs text-gray-600">
|
||||
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
|
||||
</p>
|
||||
{taggerModelProgress?.current_file ? <p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
|
||||
{taggerModelError ? <p className="mt-3 text-xs text-amber-300">{taggerModelError}</p> : null}
|
||||
{taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
|
||||
{taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null}
|
||||
{taggerRuntimeProbe ? (
|
||||
<div className="mt-4 rounded-lg border border-white/[0.07] bg-black/20 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-medium text-gray-400">Runtime check</p>
|
||||
<StatusPill tone="ready">Ready</StatusPill>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600">Tagger acceleration: {taggerRuntimeProbe.acceleration}</p>
|
||||
<p className="mt-2 break-all text-xs text-gray-500">{taggerRuntimeProbe.session.file}</p>
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
|
||||
<span className="ml-2 text-gray-600">acceleration: {taggerRuntimeProbe.acceleration}</span>
|
||||
</p>
|
||||
<p className="mt-1.5 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsCard title="Queue Targets" description="Choose which folders to include when queuing tagging jobs.">
|
||||
<SettingsRow title="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
|
||||
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
|
||||
<SettingsGroup title="Queue targets" description="Choose which folders to include when queuing tagging jobs.">
|
||||
<SettingsItem label="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
|
||||
<div className="flex rounded-lg border border-white/[0.07] p-0.5">
|
||||
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
|
||||
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsItem>
|
||||
|
||||
<div className="border-b border-white/[0.07] py-4">
|
||||
<div className="py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Folder selection</p>
|
||||
<p className="text-sm text-white">Folder selection</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -526,7 +527,7 @@ export function SettingsModal() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-4 grid max-h-64 gap-2 overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
|
||||
<div className={`mt-2 max-h-64 divide-y divide-white/[0.04] overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
|
||||
{folders.map((folder) => {
|
||||
const active = taggingQueueFolderIds.includes(folder.id);
|
||||
const progress = mediaJobProgress[folder.id];
|
||||
@@ -534,31 +535,27 @@ export function SettingsModal() {
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed ${
|
||||
active
|
||||
? "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]"
|
||||
className={`flex w-full items-center justify-between gap-3 px-1 py-2 text-left transition-colors disabled:cursor-not-allowed ${
|
||||
active ? "text-white" : "text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
onClick={() => toggleTaggingQueueFolder(folder.id)}
|
||||
disabled={taggingQueueScope === "all"}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{folder.name}</p>
|
||||
<p className="mt-1 text-[11px] text-gray-500">{folder.image_count.toLocaleString()} items</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="min-w-0 truncate text-sm">{folder.name}</p>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
|
||||
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()} items</span>
|
||||
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{folders.length === 0 ? <p className="text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
|
||||
{folders.length === 0 ? <p className="py-2 text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsRow title="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<SettingsItem label="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="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={() => runQueueAction("queue")}
|
||||
@@ -574,24 +571,20 @@ export function SettingsModal() {
|
||||
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsItem>
|
||||
|
||||
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
|
||||
</SettingsCard>
|
||||
</SectionShell>
|
||||
{taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<SectionShell
|
||||
eyebrow="General"
|
||||
title="App data"
|
||||
description="Access the folder where Phokus stores its database, thumbnails, AI models, and settings."
|
||||
<div className="mt-8 space-y-9">
|
||||
<SettingsGroup title="Storage & notifications">
|
||||
<SettingsItem
|
||||
label="App data folder"
|
||||
description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
|
||||
>
|
||||
<SettingsCard title="Storage location">
|
||||
<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">Open the app data folder in Explorer to inspect or back up files.</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"
|
||||
className="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));
|
||||
@@ -600,18 +593,11 @@ export function SettingsModal() {
|
||||
>
|
||||
{openingDataFolder ? "Opening..." : "Open data folder"}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Notifications"
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
label="Pause all notifications"
|
||||
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Pause all notifications</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">Suppress all indexing notifications until re-enabled.</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={notificationsPaused}
|
||||
@@ -620,46 +606,50 @@ export function SettingsModal() {
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
|
||||
<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
|
||||
<SettingsGroup title="Maintenance">
|
||||
<SettingsItem
|
||||
label="Compact database"
|
||||
description={
|
||||
<>
|
||||
<span>Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time.</span>
|
||||
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
|
||||
<StatPair
|
||||
label="Size"
|
||||
value={
|
||||
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`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<StatPair
|
||||
label="Reclaimable"
|
||||
accent={vacuumResult !== null}
|
||||
value={
|
||||
vacuumResult
|
||||
? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
|
||||
: dbInfo
|
||||
? `${dbInfo.reclaimable_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">
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-2 block text-gray-600">
|
||||
{vacuumResult
|
||||
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
|
||||
: dbInfo && dbInfo.reclaimable_mb < 0.5
|
||||
? "Database is already compact."
|
||||
: "Run this after removing folders or bulk-deleting images."}
|
||||
</p>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
className="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);
|
||||
@@ -671,47 +661,41 @@ export function SettingsModal() {
|
||||
.catch(() => {})
|
||||
.finally(() => setVacuuming(false));
|
||||
}}
|
||||
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5 && vacuumResult === null)}
|
||||
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
|
||||
>
|
||||
{vacuuming ? "Compacting..." : "Compact now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsCard
|
||||
title="Thumbnail cache"
|
||||
description="Thumbnails left behind when folders or images are removed. Safe to delete — they will be regenerated if the original files are re-indexed."
|
||||
>
|
||||
<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">
|
||||
{cleaningThumbnails
|
||||
? "—"
|
||||
: thumbnailCleanupResult
|
||||
<SettingsItem
|
||||
label="Thumbnail cache"
|
||||
description={
|
||||
<>
|
||||
<span>Thumbnails left behind when folders or images are removed. Safe to delete — they are regenerated if the originals are re-indexed.</span>
|
||||
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
|
||||
<StatPair
|
||||
label="Orphaned files"
|
||||
value={
|
||||
thumbnailCleanupResult
|
||||
? "0"
|
||||
: 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"}`}>
|
||||
{cleaningThumbnails
|
||||
? "—"
|
||||
: thumbnailCleanupResult
|
||||
? "0 MB"
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<StatPair
|
||||
label="Reclaimable"
|
||||
accent={thumbnailCleanupResult !== null}
|
||||
value={
|
||||
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">
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-2 block text-gray-600">
|
||||
{cleaningThumbnails
|
||||
? "Scanning and removing orphaned thumbnails…"
|
||||
: thumbnailCleanupResult
|
||||
@@ -719,11 +703,14 @@ export function SettingsModal() {
|
||||
: thumbnailInfo && thumbnailInfo.count === 0
|
||||
? "No orphaned thumbnails found."
|
||||
: thumbnailInfo && thumbnailInfo.count > 1000
|
||||
? "Remove thumbnails no longer associated with any indexed image. This may take a few minutes for large collections."
|
||||
? "May take a few minutes for large collections."
|
||||
: "Remove thumbnails no longer associated with any indexed image."}
|
||||
</p>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
className="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={() => {
|
||||
setCleaningThumbnails(true);
|
||||
cleanupOrphanedThumbnails()
|
||||
@@ -738,10 +725,8 @@ export function SettingsModal() {
|
||||
>
|
||||
{cleaningThumbnails ? "Cleaning…" : "Clean up"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SectionShell>
|
||||
</SettingsItem>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||
|
||||
const ACCENTS = [
|
||||
"#60a5fa",
|
||||
@@ -317,7 +318,9 @@ export function TagCloud() {
|
||||
: "No tags — run the AI tagger or add tags manually"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<FolderScopeDropdown />
|
||||
<div className="flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
|
||||
<button
|
||||
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
||||
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
||||
@@ -337,6 +340,7 @@ export function TagCloud() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||
|
||||
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
||||
{ value: "date_desc", label: "Newest first" },
|
||||
@@ -162,6 +163,7 @@ export function Toolbar() {
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||
const activeView = useGalleryStore((state) => state.activeView);
|
||||
|
||||
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
||||
|
||||
@@ -269,6 +271,8 @@ export function Toolbar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search */}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
const CONTROLS_HIDE_DELAY_MS = 2500;
|
||||
const SEEK_STEP_SECONDS = 5;
|
||||
const VOLUME_STEP = 0.1;
|
||||
|
||||
// Session-wide playback preferences shared across player instances.
|
||||
let persistedVolume = 1;
|
||||
let persistedMuted = false;
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const total = Math.floor(seconds);
|
||||
const s = total % 60;
|
||||
const m = Math.floor(total / 60) % 60;
|
||||
const h = Math.floor(total / 3600);
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
interface BufferedRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function ControlButton({ onClick, title, active = false, children }: {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoPlayer({ src }: { src: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrubbingRef = useRef(false);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
|
||||
const [volume, setVolume] = useState(persistedVolume);
|
||||
const [muted, setMuted] = useState(persistedMuted);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [loop, setLoop] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
|
||||
|
||||
const speedMenuOpenRef = useRef(speedMenuOpen);
|
||||
speedMenuOpenRef.current = speedMenuOpen;
|
||||
|
||||
// ── Controls visibility ────────────────────────────────────────────────────
|
||||
const showControls = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
const video = videoRef.current;
|
||||
if (video && !video.paused && !scrubbingRef.current && !speedMenuOpenRef.current) {
|
||||
setControlsVisible(false);
|
||||
}
|
||||
}, CONTROLS_HIDE_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Video element wiring ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.volume = persistedVolume;
|
||||
video.muted = persistedMuted;
|
||||
// Autoplay; if the webview blocks it the catch leaves us paused with
|
||||
// controls visible, which is a fine fallback.
|
||||
video.play().catch(() => {});
|
||||
}, [src]);
|
||||
|
||||
const readBuffered = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
|
||||
const ranges: BufferedRange[] = [];
|
||||
for (let i = 0; i < video.buffered.length; i++) {
|
||||
ranges.push({
|
||||
start: video.buffered.start(i) / video.duration,
|
||||
end: video.buffered.end(i) / video.duration,
|
||||
});
|
||||
}
|
||||
setBuffered(ranges);
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
void video.play().catch(() => {});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
showControls();
|
||||
}, [showControls]);
|
||||
|
||||
const seekBy = useCallback(
|
||||
(deltaSeconds: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !Number.isFinite(video.duration)) return;
|
||||
video.currentTime = Math.min(video.duration, Math.max(0, video.currentTime + deltaSeconds));
|
||||
showControls();
|
||||
},
|
||||
[showControls],
|
||||
);
|
||||
|
||||
const applyVolume = useCallback(
|
||||
(nextVolume: number, nextMuted?: boolean) => {
|
||||
const video = videoRef.current;
|
||||
const clamped = Math.min(1, Math.max(0, nextVolume));
|
||||
setVolume(clamped);
|
||||
persistedVolume = clamped;
|
||||
if (nextMuted !== undefined) {
|
||||
setMuted(nextMuted);
|
||||
persistedMuted = nextMuted;
|
||||
if (video) video.muted = nextMuted;
|
||||
}
|
||||
if (video) video.volume = clamped;
|
||||
showControls();
|
||||
},
|
||||
[showControls],
|
||||
);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const next = !muted;
|
||||
setMuted(next);
|
||||
persistedMuted = next;
|
||||
const video = videoRef.current;
|
||||
if (video) video.muted = next;
|
||||
showControls();
|
||||
}, [muted, showControls]);
|
||||
|
||||
const toggleLoop = useCallback(() => {
|
||||
setLoop((value) => {
|
||||
const video = videoRef.current;
|
||||
if (video) video.loop = !value;
|
||||
return !value;
|
||||
});
|
||||
showControls();
|
||||
}, [showControls]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
} else {
|
||||
void containerRef.current?.requestFullscreen().catch(() => {});
|
||||
}
|
||||
showControls();
|
||||
}, [showControls]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setFullscreen(document.fullscreenElement !== null);
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
}, []);
|
||||
|
||||
const setSpeed = useCallback(
|
||||
(rate: number) => {
|
||||
setPlaybackRate(rate);
|
||||
const video = videoRef.current;
|
||||
if (video) video.playbackRate = rate;
|
||||
setSpeedMenuOpen(false);
|
||||
showControls();
|
||||
},
|
||||
[showControls],
|
||||
);
|
||||
|
||||
// ── Scrubber ───────────────────────────────────────────────────────────────
|
||||
const seekToPointer = useCallback((clientX: number) => {
|
||||
const video = videoRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!video || !track || !Number.isFinite(video.duration) || video.duration <= 0) return;
|
||||
const bounds = track.getBoundingClientRect();
|
||||
const fraction = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width));
|
||||
video.currentTime = fraction * video.duration;
|
||||
setCurrentTime(video.currentTime);
|
||||
}, []);
|
||||
|
||||
const handleTrackPointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
scrubbingRef.current = true;
|
||||
seekToPointer(event.clientX);
|
||||
},
|
||||
[seekToPointer],
|
||||
);
|
||||
|
||||
const handleTrackPointerMove = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!scrubbingRef.current) return;
|
||||
seekToPointer(event.clientX);
|
||||
},
|
||||
[seekToPointer],
|
||||
);
|
||||
|
||||
const handleTrackPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
scrubbingRef.current = false;
|
||||
}, []);
|
||||
|
||||
// ── Keyboard ───────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
toggleMute();
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case "l":
|
||||
case "L":
|
||||
toggleLoop();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
seekBy(-SEEK_STEP_SECONDS);
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
seekBy(SEEK_STEP_SECONDS);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
// Adjusting volume always unmutes, matching the slider behavior.
|
||||
applyVolume(persistedVolume + VOLUME_STEP, false);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
applyVolume(persistedVolume - VOLUME_STEP, false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, toggleLoop, seekBy, applyVolume]);
|
||||
|
||||
const playedFraction = duration > 0 ? currentTime / duration : 0;
|
||||
const effectiveVolume = muted ? 0 : volume;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
|
||||
onPointerMove={showControls}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
className="h-full w-full object-contain"
|
||||
onClick={togglePlay}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onPlay={() => {
|
||||
setPlaying(true);
|
||||
showControls();
|
||||
}}
|
||||
onPause={() => {
|
||||
setPlaying(false);
|
||||
setControlsVisible(true);
|
||||
}}
|
||||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(event) => {
|
||||
setDuration(event.currentTarget.duration);
|
||||
readBuffered();
|
||||
}}
|
||||
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
|
||||
onProgress={readBuffered}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{controlsVisible ? (
|
||||
<motion.div
|
||||
className="absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-2.5 pt-12"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* Scrubber */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="group/track relative flex h-4 cursor-pointer items-center"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
onPointerMove={handleTrackPointerMove}
|
||||
onPointerUp={handleTrackPointerUp}
|
||||
>
|
||||
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/15 transition-[height] group-hover/track:h-1.5">
|
||||
{buffered.map((range, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute inset-y-0 bg-white/20"
|
||||
style={{ left: `${range.start * 100}%`, width: `${(range.end - range.start) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute inset-y-0 left-0 bg-white/90" style={{ width: `${playedFraction * 100}%` }} />
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover/track:opacity-100"
|
||||
style={{ left: `${playedFraction * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control row */}
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<ControlButton onClick={togglePlay} title={playing ? "Pause (Space)" : "Play (Space)"}>
|
||||
{playing ? (
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 5h4v14H7zM13 5h4v14h-4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5.14v13.72L19 12 8 5.14z" />
|
||||
</svg>
|
||||
)}
|
||||
</ControlButton>
|
||||
|
||||
<span className="ml-1 text-xs tabular-nums text-gray-300">
|
||||
{formatTime(currentTime)} <span className="text-gray-500">/ {formatTime(duration)}</span>
|
||||
</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Volume */}
|
||||
<ControlButton onClick={toggleMute} title={muted ? "Unmute (M)" : "Mute (M)"}>
|
||||
{effectiveVolume === 0 ? (
|
||||
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 9l4 6m0-6l-4 6" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728" />
|
||||
</svg>
|
||||
)}
|
||||
</ControlButton>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.02}
|
||||
value={effectiveVolume}
|
||||
className="h-1 w-20 cursor-pointer accent-white"
|
||||
onChange={(event) => applyVolume(parseFloat(event.target.value), false)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
title="Volume (↑/↓)"
|
||||
/>
|
||||
|
||||
{/* Playback speed */}
|
||||
<div className="relative">
|
||||
<button
|
||||
className={`min-w-12 rounded-md px-2 py-1.5 text-xs tabular-nums transition-colors hover:bg-white/10 ${playbackRate !== 1 ? "text-white" : "text-gray-300 hover:text-white"}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSpeedMenuOpen((value) => !value);
|
||||
}}
|
||||
title="Playback speed"
|
||||
>
|
||||
{playbackRate}×
|
||||
</button>
|
||||
{speedMenuOpen ? (
|
||||
<div className="absolute bottom-full right-0 z-20 mb-2 min-w-20 rounded-lg border border-white/10 bg-gray-950/95 p-1 shadow-2xl backdrop-blur">
|
||||
{SPEED_OPTIONS.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
className={`flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-xs tabular-nums transition-colors ${
|
||||
rate === playbackRate ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSpeed(rate);
|
||||
}}
|
||||
>
|
||||
{rate}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Loop */}
|
||||
<ControlButton onClick={toggleLoop} title={loop ? "Loop on (L)" : "Loop off (L)"} active={loop}>
|
||||
<svg className={`h-4.5 w-4.5 ${loop ? "text-emerald-300" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
|
||||
</svg>
|
||||
</ControlButton>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<ControlButton onClick={toggleFullscreen} title={fullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)"}>
|
||||
{fullscreen ? (
|
||||
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9H4m5 0V4m6 5h5m-5 0V4M9 15H4m5 0v5m6-5h5m-5 0v5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4" />
|
||||
</svg>
|
||||
)}
|
||||
</ControlButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -337,6 +337,7 @@ interface GalleryState {
|
||||
renameFolder: (folderId: number, newName: string) => Promise<void>;
|
||||
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
|
||||
selectFolder: (folderId: number | null) => void;
|
||||
setViewFolderScope: (folderId: number | null) => void;
|
||||
loadImages: (reset?: boolean) => Promise<void>;
|
||||
loadMoreImages: () => Promise<void>;
|
||||
setSearch: (search: string) => void;
|
||||
@@ -755,6 +756,34 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
// Change folder scope from inside a feature view (Timeline/Explore/Duplicates)
|
||||
// without leaving it — unlike selectFolder, activeView is preserved.
|
||||
setViewFolderScope: (folderId) => {
|
||||
const { activeView, selectedFolderId } = get();
|
||||
if (folderId === selectedFolderId) return;
|
||||
|
||||
set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||
|
||||
if (activeView === "duplicates") {
|
||||
const { duplicateScanFolderId } = get();
|
||||
if (duplicateScanFolderId !== folderId) {
|
||||
set({
|
||||
duplicateGroups: [],
|
||||
duplicateLastScanned: null,
|
||||
duplicateScanFolderId: undefined,
|
||||
duplicateScanWarning: null,
|
||||
});
|
||||
void get().loadDuplicateScanCache(folderId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Explore reloads itself via TagCloud's useEffect on selectedFolderId.
|
||||
if (activeView === "explore") return;
|
||||
|
||||
void get().loadImages(true);
|
||||
},
|
||||
|
||||
loadImages: async (reset = false) => {
|
||||
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
|
||||
const parsedSearch = parseSearchValue(search);
|
||||
|
||||
Reference in New Issue
Block a user