Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cd3bbd4fd | |||
| 86ce7bc8e2 | |||
| b02bf1da2b | |||
| cd7dd89f00 |
@@ -4,16 +4,30 @@ A local-first desktop media library for browsing, filtering, and curating image
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Library
|
||||||
- Add and remove media folders; background indexing with live progress
|
- 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
|
- 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`)
|
- Sort by date added, date taken (EXIF), name, size, rating, or duration
|
||||||
- **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
|
|
||||||
- Grid density controls (compact / comfortable / detail)
|
- 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
|
## Supported formats
|
||||||
|
|
||||||
@@ -26,9 +40,10 @@ A local-first desktop media library for browsing, filtering, and curating image
|
|||||||
|
|
||||||
- Tauri 2 + Rust backend
|
- Tauri 2 + Rust backend
|
||||||
- React 19 + TypeScript + Zustand
|
- React 19 + TypeScript + Zustand
|
||||||
- SQLite + `sqlite-vec` (vector search)
|
- SQLite + `sqlite-vec` (vector search) + HNSW index
|
||||||
- ONNX Runtime (`ort`) for AI tagging
|
- 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
|
- FFmpeg sidecar for video thumbnails and metadata
|
||||||
- Vite + Tailwind CSS v4
|
- Vite + Tailwind CSS v4
|
||||||
|
|
||||||
@@ -47,12 +62,16 @@ pnpm dev:vite
|
|||||||
|
|
||||||
# Production build
|
# Production build
|
||||||
pnpm build:app
|
pnpm build:app
|
||||||
|
|
||||||
|
# Type-check the frontend
|
||||||
|
pnpm build:vite
|
||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. Add a folder from the sidebar — the Rust indexer walks it recursively.
|
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.).
|
2. Supported files are written to SQLite with metadata (path, dimensions, media type, EXIF capture date, etc.).
|
||||||
3. Background workers generate thumbnails, compute visual embeddings, and run AI tagging.
|
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.
|
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 { useState } from "react";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { DuplicateGroup, useGalleryStore } from "../store";
|
import { DuplicateGroup, useGalleryStore } from "../store";
|
||||||
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
||||||
@@ -188,6 +189,7 @@ export function DuplicateFinder() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderScopeDropdown />
|
||||||
{/* Batch select — only shown when there are groups and nothing is selected yet */}
|
{/* Batch select — only shown when there are groups and nothing is selected yet */}
|
||||||
{hasResults && selectedCount === 0 && !deleting && (
|
{hasResults && selectedCount === 0 && !deleting && (
|
||||||
<button
|
<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 { motion, AnimatePresence } from "framer-motion";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { useGalleryStore, ImageTag, AiRating } from "../store";
|
import { useGalleryStore, ImageTag, AiRating } from "../store";
|
||||||
|
import { VideoPlayer } from "./VideoPlayer";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -223,8 +224,9 @@ export function Lightbox() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (regionSelectMode) return; // block nav keys during selection
|
if (regionSelectMode) return; // block nav keys during selection
|
||||||
if (event.key === "ArrowLeft") goPrev();
|
// Shift+arrows are reserved for video seeking (handled by VideoPlayer)
|
||||||
if (event.key === "ArrowRight") goNext();
|
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 === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
|
||||||
if (event.key === "-") setZoom((value) => Math.max(0.75, 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">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={selectedImage.id}
|
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 }}
|
initial={{ opacity: 0.3, scale: 0.985 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0.3, scale: 0.985 }}
|
exit={{ opacity: 0.3, scale: 0.985 }}
|
||||||
transition={{ duration: 0.12 }}
|
transition={{ duration: 0.12 }}
|
||||||
>
|
>
|
||||||
{selectedImage.media_kind === "video" ? (
|
{selectedImage.media_kind === "video" ? (
|
||||||
<video
|
<VideoPlayer src={convertFileSrc(selectedImage.path)} />
|
||||||
src={convertFileSrc(selectedImage.path)}
|
|
||||||
controls
|
|
||||||
className="max-h-full max-w-full rounded-2xl shadow-2xl"
|
|
||||||
style={{ maxHeight: "calc(100vh - 10rem)" }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
|
|||||||
+413
-428
@@ -16,46 +16,56 @@ function StatusPill({ children, tone }: { children: React.ReactNode; tone: "read
|
|||||||
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
|
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
|
||||||
: "border-white/10 bg-white/[0.04] text-gray-500";
|
: "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 }: {
|
function SettingsGroup({ title, description, children }: {
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p>
|
<h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">{title}</h4>
|
||||||
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3>
|
{description ? <p className="mt-1 text-xs leading-relaxed text-gray-600">{description}</p> : null}
|
||||||
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-gray-500">{description}</p> : null}
|
<div className="mt-1 divide-y divide-white/[0.05]">{children}</div>
|
||||||
<div className="mt-5 space-y-4">{children}</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
function SettingsItem({ label, description, children, vertical = false }: {
|
||||||
return (
|
label: React.ReactNode;
|
||||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5">
|
description?: React.ReactNode;
|
||||||
<div className="flex flex-col gap-1 border-b border-white/[0.07] pb-4">
|
children?: React.ReactNode;
|
||||||
<p className="text-sm font-medium text-white">{title}</p>
|
vertical?: boolean;
|
||||||
{description ? <p className="text-xs leading-relaxed text-gray-500">{description}</p> : null}
|
}) {
|
||||||
|
if (vertical) {
|
||||||
|
return (
|
||||||
|
<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>
|
</div>
|
||||||
<div className="pt-4">{children}</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-6 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
|
||||||
return (
|
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">
|
<span className="inline-flex items-baseline gap-2">
|
||||||
<div className="min-w-0">
|
<span className="text-[10px] uppercase tracking-[0.14em] text-gray-600">{label}</span>
|
||||||
<p className="text-sm font-medium text-white">{title}</p>
|
<span className={`text-sm font-semibold tabular-nums ${accent ? "text-emerald-300" : "text-white"}`}>{value}</span>
|
||||||
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="shrink-0">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +82,7 @@ function ScopeButton({ scope, current, onSelect, children }: {
|
|||||||
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||||
active
|
active
|
||||||
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
|
? "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)}
|
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 ${
|
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||||
active
|
active
|
||||||
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
|
? "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)}
|
onClick={() => onSelect(acceleration)}
|
||||||
>
|
>
|
||||||
@@ -277,7 +287,7 @@ export function SettingsModal() {
|
|||||||
return (
|
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="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
|
||||||
<div
|
<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()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
|
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
|
||||||
@@ -301,417 +311,391 @@ export function SettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<button
|
||||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6">
|
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"
|
||||||
<div>
|
onClick={() => setSettingsOpen(false)}
|
||||||
<p className="text-sm font-medium text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</p>
|
title="Close settings"
|
||||||
<p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
|
>
|
||||||
</div>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<button
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
</svg>
|
||||||
onClick={() => setSettingsOpen(false)}
|
</button>
|
||||||
title="Close settings"
|
|
||||||
>
|
<main className="min-w-0 flex-1 overflow-y-auto">
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<div className="px-10 py-8">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<h3 className="text-lg font-semibold text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</h3>
|
||||||
</svg>
|
<p className="mt-1 text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-7 py-6">
|
|
||||||
{activeSection === "workspace" ? (
|
{activeSection === "workspace" ? (
|
||||||
<div className="space-y-8">
|
<div className="mt-8 space-y-9">
|
||||||
<SectionShell
|
<SettingsGroup title="Model">
|
||||||
eyebrow="AI Workspace"
|
<SettingsItem
|
||||||
title="Tagging"
|
label={
|
||||||
>
|
<>
|
||||||
<SettingsCard title="Tagging Models">
|
WD SwinV2 Tagger v3{" "}
|
||||||
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
<span className="ml-1.5 align-middle">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
|
||||||
<div>
|
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
|
||||||
<div className="flex items-center gap-2">
|
</StatusPill>
|
||||||
<p className="text-sm font-medium text-white">WD SwinV2 Tagger v3</p>
|
</span>
|
||||||
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
|
</>
|
||||||
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
|
}
|
||||||
</StatusPill>
|
description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds."
|
||||||
</div>
|
>
|
||||||
<p className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">
|
<div className="flex items-center gap-2">
|
||||||
Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.
|
{taggerReady ? (
|
||||||
</p>
|
<>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<button
|
<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"
|
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={() => void prepareTaggerModel()}
|
onClick={() => void probeTaggerRuntime()}
|
||||||
disabled={taggerModelPreparing || taggerReady}
|
disabled={taggerRuntimeChecking}
|
||||||
>
|
>
|
||||||
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
|
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
|
||||||
<span className="relative">{taggerDownloadLabel}</span>
|
|
||||||
</button>
|
</button>
|
||||||
{taggerReady ? (
|
<button
|
||||||
<>
|
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
<button
|
onClick={() => void deleteTaggerModel()}
|
||||||
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"
|
disabled={taggerModelPreparing}
|
||||||
onClick={() => void probeTaggerRuntime()}
|
>
|
||||||
disabled={taggerRuntimeChecking}
|
Delete model files
|
||||||
>
|
</button>
|
||||||
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
|
</>
|
||||||
</button>
|
) : (
|
||||||
<button
|
<button
|
||||||
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
|
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 deleteTaggerModel()}
|
onClick={() => void prepareTaggerModel()}
|
||||||
disabled={taggerModelPreparing}
|
disabled={taggerModelPreparing}
|
||||||
>
|
>
|
||||||
Delete model files
|
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
|
||||||
</button>
|
<span className="relative">{taggerDownloadLabel}</span>
|
||||||
</>
|
</button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
|
|
||||||
<TaggerAccelerationButton
|
|
||||||
key={acceleration}
|
|
||||||
acceleration={acceleration}
|
|
||||||
current={taggerAcceleration}
|
|
||||||
onSelect={(nextAcceleration) => {
|
|
||||||
setTaggerAccelerationSaving(true);
|
|
||||||
setTaggerAccelerationError(null);
|
|
||||||
void setTaggerAcceleration(nextAcceleration)
|
|
||||||
.catch((error: unknown) => setTaggerAccelerationError(String(error)))
|
|
||||||
.finally(() => setTaggerAccelerationSaving(false));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
|
|
||||||
</TaggerAccelerationButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{taggerAccelerationError ? (
|
|
||||||
<p className="text-[11px] text-amber-300">{taggerAccelerationError}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0.05"
|
|
||||||
max="0.99"
|
|
||||||
step="0.05"
|
|
||||||
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
|
|
||||||
value={thresholdDisplay}
|
|
||||||
onChange={(event) => setTaggerThresholdDraft(event.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
const value = parseFloat(thresholdDisplay);
|
|
||||||
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
|
|
||||||
setTaggerThresholdError(null);
|
|
||||||
setTaggerThresholdSaving(true);
|
|
||||||
void setTaggerThreshold(value)
|
|
||||||
.catch((error: unknown) => setTaggerThresholdError(String(error)))
|
|
||||||
.finally(() => {
|
|
||||||
setTaggerThresholdDraft(null);
|
|
||||||
setTaggerThresholdSaving(false);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setTaggerThresholdDraft(null);
|
|
||||||
setTaggerThresholdError("Must be 0.05 – 0.99");
|
|
||||||
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
|
|
||||||
thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{taggerThresholdError ? (
|
|
||||||
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow title="Tagging batch size" description="Number of images processed concurrently during tag generation.">
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
step="1"
|
|
||||||
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
|
|
||||||
value={batchSizeDisplay}
|
|
||||||
onChange={(event) => setTaggerBatchSizeDraft(event.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
const value = parseInt(batchSizeDisplay, 10);
|
|
||||||
if (!isNaN(value) && value >= 1 && value <= 100) {
|
|
||||||
setTaggerBatchSizeError(null);
|
|
||||||
setTaggerBatchSizeSaving(true);
|
|
||||||
void setTaggerBatchSize(value)
|
|
||||||
.catch((error: unknown) => setTaggerQueueStatus(String(error)))
|
|
||||||
.finally(() => {
|
|
||||||
setTaggerBatchSizeDraft(null);
|
|
||||||
setTaggerBatchSizeSaving(false);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setTaggerBatchSizeDraft(null);
|
|
||||||
setTaggerBatchSizeError("Must be 1 – 100");
|
|
||||||
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
|
|
||||||
batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{taggerBatchSizeError ? (
|
|
||||||
<p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
{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}
|
|
||||||
{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>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsCard title="Queue Targets" description="Choose which folders to include when queuing tagging jobs.">
|
<SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
|
||||||
<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 flex-col items-end gap-1.5">
|
||||||
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
|
<div className="flex rounded-lg border border-white/[0.07] p-0.5">
|
||||||
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
|
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
|
||||||
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
|
<TaggerAccelerationButton
|
||||||
</div>
|
key={acceleration}
|
||||||
</SettingsRow>
|
acceleration={acceleration}
|
||||||
|
current={taggerAcceleration}
|
||||||
<div className="border-b border-white/[0.07] py-4">
|
onSelect={(nextAcceleration) => {
|
||||||
<div className="flex items-center justify-between gap-4">
|
setTaggerAccelerationSaving(true);
|
||||||
<div>
|
setTaggerAccelerationError(null);
|
||||||
<p className="text-sm font-medium text-white">Folder selection</p>
|
void setTaggerAcceleration(nextAcceleration)
|
||||||
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
|
.catch((error: unknown) => setTaggerAccelerationError(String(error)))
|
||||||
</div>
|
.finally(() => setTaggerAccelerationSaving(false));
|
||||||
<div className="flex gap-2">
|
}}
|
||||||
<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 disabled:cursor-not-allowed"
|
|
||||||
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
|
|
||||||
disabled={taggingQueueScope === "all" || folders.length === 0}
|
|
||||||
>
|
>
|
||||||
Select all
|
{acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
|
||||||
</button>
|
</TaggerAccelerationButton>
|
||||||
<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 disabled:cursor-not-allowed"
|
|
||||||
onClick={() => setTaggingQueueFolderIds([])}
|
|
||||||
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{taggerAccelerationError ? (
|
||||||
|
<p className="text-[11px] text-amber-300">{taggerAccelerationError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<div className={`mt-4 grid max-h-64 gap-2 overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
|
<SettingsItem label="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
|
||||||
{folders.map((folder) => {
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
const active = taggingQueueFolderIds.includes(folder.id);
|
<input
|
||||||
const progress = mediaJobProgress[folder.id];
|
type="number"
|
||||||
return (
|
min="0.05"
|
||||||
<button
|
max="0.99"
|
||||||
key={folder.id}
|
step="0.05"
|
||||||
type="button"
|
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
|
||||||
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed ${
|
value={thresholdDisplay}
|
||||||
active
|
onChange={(event) => setTaggerThresholdDraft(event.target.value)}
|
||||||
? "border-emerald-400/30 bg-emerald-500/10 text-white"
|
onBlur={() => {
|
||||||
: "border-white/[0.07] bg-black/20 text-gray-300 hover:border-white/15 hover:bg-white/[0.04]"
|
const value = parseFloat(thresholdDisplay);
|
||||||
}`}
|
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
|
||||||
onClick={() => toggleTaggingQueueFolder(folder.id)}
|
setTaggerThresholdError(null);
|
||||||
disabled={taggingQueueScope === "all"}
|
setTaggerThresholdSaving(true);
|
||||||
>
|
void setTaggerThreshold(value)
|
||||||
<div>
|
.catch((error: unknown) => setTaggerThresholdError(String(error)))
|
||||||
<p className="text-sm font-medium">{folder.name}</p>
|
.finally(() => {
|
||||||
<p className="mt-1 text-[11px] text-gray-500">{folder.image_count.toLocaleString()} items</p>
|
setTaggerThresholdDraft(null);
|
||||||
</div>
|
setTaggerThresholdSaving(false);
|
||||||
<div className="flex items-center gap-2">
|
});
|
||||||
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
|
} else {
|
||||||
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
|
setTaggerThresholdDraft(null);
|
||||||
</div>
|
setTaggerThresholdError("Must be 0.05 – 0.99");
|
||||||
</button>
|
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
|
||||||
);
|
thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
|
||||||
})}
|
}
|
||||||
{folders.length === 0 ? <p className="text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
|
}}
|
||||||
|
/>
|
||||||
|
{taggerThresholdError ? (
|
||||||
|
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
|
||||||
|
value={batchSizeDisplay}
|
||||||
|
onChange={(event) => setTaggerBatchSizeDraft(event.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const value = parseInt(batchSizeDisplay, 10);
|
||||||
|
if (!isNaN(value) && value >= 1 && value <= 100) {
|
||||||
|
setTaggerBatchSizeError(null);
|
||||||
|
setTaggerBatchSizeSaving(true);
|
||||||
|
void setTaggerBatchSize(value)
|
||||||
|
.catch((error: unknown) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => {
|
||||||
|
setTaggerBatchSizeDraft(null);
|
||||||
|
setTaggerBatchSizeSaving(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTaggerBatchSizeDraft(null);
|
||||||
|
setTaggerBatchSizeError("Must be 1 – 100");
|
||||||
|
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
|
||||||
|
batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{taggerBatchSizeError ? (
|
||||||
|
<p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem label="Model location" vertical>
|
||||||
|
<div>
|
||||||
|
<p className="break-all font-mono text-xs text-gray-600">
|
||||||
|
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
|
||||||
|
</p>
|
||||||
|
{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-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>
|
||||||
|
</SettingsItem>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
<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 disabled:cursor-not-allowed"
|
||||||
|
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
|
||||||
|
disabled={taggingQueueScope === "all" || folders.length === 0}
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</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 disabled:cursor-not-allowed"
|
||||||
|
onClick={() => setTaggingQueueFolderIds([])}
|
||||||
|
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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={`mt-2 max-h-64 divide-y divide-white/[0.04] overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
|
||||||
<div className="flex flex-col items-end gap-2">
|
{folders.map((folder) => {
|
||||||
<button
|
const active = taggingQueueFolderIds.includes(folder.id);
|
||||||
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"
|
const progress = mediaJobProgress[folder.id];
|
||||||
onClick={() => runQueueAction("queue")}
|
return (
|
||||||
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
|
<button
|
||||||
>
|
key={folder.id}
|
||||||
{taggerQueueing ? "Queueing..." : "Queue tagging"}
|
type="button"
|
||||||
</button>
|
className={`flex w-full items-center justify-between gap-3 px-1 py-2 text-left transition-colors disabled:cursor-not-allowed ${
|
||||||
<button
|
active ? "text-white" : "text-gray-400 hover:text-gray-200"
|
||||||
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
|
}`}
|
||||||
onClick={() => runQueueAction("clear")}
|
onClick={() => toggleTaggingQueueFolder(folder.id)}
|
||||||
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
|
disabled={taggingQueueScope === "all"}
|
||||||
>
|
>
|
||||||
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
|
<p className="min-w-0 truncate text-sm">{folder.name}</p>
|
||||||
</button>
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
</div>
|
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
|
||||||
</SettingsRow>
|
<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="py-2 text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
|
<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.">
|
||||||
</SettingsCard>
|
<div className="flex items-center gap-2">
|
||||||
</SectionShell>
|
<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")}
|
||||||
|
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
|
||||||
|
>
|
||||||
|
{taggerQueueing ? "Queueing..." : "Queue tagging"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => runQueueAction("clear")}
|
||||||
|
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
|
||||||
|
>
|
||||||
|
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
{taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
|
||||||
|
</SettingsGroup>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="mt-8 space-y-9">
|
||||||
<SectionShell
|
<SettingsGroup title="Storage & notifications">
|
||||||
eyebrow="General"
|
<SettingsItem
|
||||||
title="App data"
|
label="App data folder"
|
||||||
description="Access the folder where Phokus stores its database, thumbnails, AI models, and settings."
|
description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
|
||||||
>
|
>
|
||||||
<SettingsCard title="Storage location">
|
<button
|
||||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
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"
|
||||||
<p className="text-sm text-gray-400">Open the app data folder in Explorer to inspect or back up files.</p>
|
onClick={() => {
|
||||||
<button
|
setOpeningDataFolder(true);
|
||||||
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"
|
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
|
||||||
onClick={() => {
|
}}
|
||||||
setOpeningDataFolder(true);
|
disabled={openingDataFolder}
|
||||||
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
|
>
|
||||||
}}
|
{openingDataFolder ? "Opening..." : "Open data folder"}
|
||||||
disabled={openingDataFolder}
|
</button>
|
||||||
>
|
</SettingsItem>
|
||||||
{openingDataFolder ? "Opening..." : "Open data folder"}
|
<SettingsItem
|
||||||
</button>
|
label="Pause all notifications"
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<SettingsCard
|
|
||||||
title="Notifications"
|
|
||||||
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
|
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">
|
<button
|
||||||
<div>
|
role="switch"
|
||||||
<p className="text-sm font-medium text-white">Pause all notifications</p>
|
aria-checked={notificationsPaused}
|
||||||
<p className="mt-0.5 text-xs text-gray-500">Suppress all indexing notifications until re-enabled.</p>
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
|
||||||
</div>
|
onClick={() => setNotificationsPaused(!notificationsPaused)}
|
||||||
<button
|
>
|
||||||
role="switch"
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
|
||||||
aria-checked={notificationsPaused}
|
</button>
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
|
</SettingsItem>
|
||||||
onClick={() => setNotificationsPaused(!notificationsPaused)}
|
</SettingsGroup>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsGroup title="Maintenance">
|
||||||
title="Compact database"
|
<SettingsItem
|
||||||
description="Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time."
|
label="Compact database"
|
||||||
>
|
description={
|
||||||
<div className="space-y-3">
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<span>Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time.</span>
|
||||||
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
|
||||||
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Database size</p>
|
<StatPair
|
||||||
<p className="mt-2 text-2xl font-semibold text-white">
|
label="Size"
|
||||||
{vacuumResult
|
value={
|
||||||
? `${vacuumResult.after_mb.toFixed(1)} MB`
|
vacuumResult
|
||||||
: dbInfo
|
? `${vacuumResult.after_mb.toFixed(1)} MB`
|
||||||
? `${dbInfo.size_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>
|
<StatPair
|
||||||
<p className={`mt-2 text-2xl font-semibold ${vacuumResult ? "text-emerald-300" : "text-white"}`}>
|
label="Reclaimable"
|
||||||
{vacuumResult
|
accent={vacuumResult !== null}
|
||||||
? `−${vacuumResult.freed_mb.toFixed(1)} MB freed`
|
value={
|
||||||
: dbInfo
|
vacuumResult
|
||||||
? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
|
? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
|
||||||
: "—"}
|
: dbInfo
|
||||||
</p>
|
? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
|
||||||
</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
|
{vacuumResult
|
||||||
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
|
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
|
||||||
: dbInfo && dbInfo.reclaimable_mb < 0.5
|
: dbInfo && dbInfo.reclaimable_mb < 0.5
|
||||||
? "Database is already compact."
|
? "Database is already compact."
|
||||||
: "Run this after removing folders or bulk-deleting images."}
|
: "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"
|
}
|
||||||
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>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<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">
|
<button
|
||||||
<div className="grid grid-cols-2 gap-3">
|
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"
|
||||||
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
onClick={() => {
|
||||||
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Orphaned files</p>
|
setVacuuming(true);
|
||||||
<p className="mt-2 text-2xl font-semibold text-white">
|
setVacuumResult(null);
|
||||||
{cleaningThumbnails
|
void vacuumDatabase()
|
||||||
? "—"
|
.then((result) => {
|
||||||
: thumbnailCleanupResult
|
setVacuumResult(result);
|
||||||
|
setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setVacuuming(false));
|
||||||
|
}}
|
||||||
|
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
|
||||||
|
>
|
||||||
|
{vacuuming ? "Compacting..." : "Compact now"}
|
||||||
|
</button>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<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"
|
? "0"
|
||||||
: thumbnailInfo
|
: thumbnailInfo
|
||||||
? thumbnailInfo.count.toLocaleString()
|
? thumbnailInfo.count.toLocaleString()
|
||||||
: "—"}
|
: "—"
|
||||||
</p>
|
}
|
||||||
</div>
|
/>
|
||||||
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
<StatPair
|
||||||
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Reclaimable</p>
|
label="Reclaimable"
|
||||||
<p className={`mt-2 text-2xl font-semibold ${thumbnailCleanupResult ? "text-emerald-300" : "text-white"}`}>
|
accent={thumbnailCleanupResult !== null}
|
||||||
{cleaningThumbnails
|
value={
|
||||||
? "—"
|
thumbnailCleanupResult
|
||||||
: thumbnailCleanupResult
|
? `${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed`
|
||||||
? "0 MB"
|
|
||||||
: thumbnailInfo
|
: thumbnailInfo
|
||||||
? `${thumbnailInfo.size_mb.toFixed(1)} MB`
|
? `${thumbnailInfo.size_mb.toFixed(1)} MB`
|
||||||
: "—"}
|
: "—"
|
||||||
</p>
|
}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
<span className="mt-2 block text-gray-600">
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
{cleaningThumbnails
|
{cleaningThumbnails
|
||||||
? "Scanning and removing orphaned thumbnails…"
|
? "Scanning and removing orphaned thumbnails…"
|
||||||
: thumbnailCleanupResult
|
: thumbnailCleanupResult
|
||||||
@@ -719,29 +703,30 @@ export function SettingsModal() {
|
|||||||
: thumbnailInfo && thumbnailInfo.count === 0
|
: thumbnailInfo && thumbnailInfo.count === 0
|
||||||
? "No orphaned thumbnails found."
|
? "No orphaned thumbnails found."
|
||||||
: thumbnailInfo && thumbnailInfo.count > 1000
|
: 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."}
|
: "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"
|
}
|
||||||
onClick={() => {
|
>
|
||||||
setCleaningThumbnails(true);
|
<button
|
||||||
cleanupOrphanedThumbnails()
|
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"
|
||||||
.then((result) => {
|
onClick={() => {
|
||||||
setThumbnailCleanupResult(result);
|
setCleaningThumbnails(true);
|
||||||
setThumbnailInfo(null);
|
cleanupOrphanedThumbnails()
|
||||||
})
|
.then((result) => {
|
||||||
.catch(() => {})
|
setThumbnailCleanupResult(result);
|
||||||
.finally(() => setCleaningThumbnails(false));
|
setThumbnailInfo(null);
|
||||||
}}
|
})
|
||||||
disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
|
.catch(() => {})
|
||||||
>
|
.finally(() => setCleaningThumbnails(false));
|
||||||
{cleaningThumbnails ? "Cleaning…" : "Clean up"}
|
}}
|
||||||
</button>
|
disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
|
||||||
</div>
|
>
|
||||||
</div>
|
{cleaningThumbnails ? "Cleaning…" : "Clean up"}
|
||||||
</SettingsCard>
|
</button>
|
||||||
</SectionShell>
|
</SettingsItem>
|
||||||
|
</SettingsGroup>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+21
-17
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
||||||
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
|
|
||||||
const ACCENTS = [
|
const ACCENTS = [
|
||||||
"#60a5fa",
|
"#60a5fa",
|
||||||
@@ -317,23 +318,26 @@ export function TagCloud() {
|
|||||||
: "No tags — run the AI tagger or add tags manually"}
|
: "No tags — run the AI tagger or add tags manually"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
||||||
<button
|
<FolderScopeDropdown />
|
||||||
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
<div className="flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
|
||||||
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
<button
|
||||||
}`}
|
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
||||||
onClick={() => setExploreMode("visual")}
|
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
>
|
}`}
|
||||||
Clusters
|
onClick={() => setExploreMode("visual")}
|
||||||
</button>
|
>
|
||||||
<button
|
Clusters
|
||||||
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
</button>
|
||||||
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
<button
|
||||||
}`}
|
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
|
||||||
onClick={() => setExploreMode("tags")}
|
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
>
|
}`}
|
||||||
Tag Cloud
|
onClick={() => setExploreMode("tags")}
|
||||||
</button>
|
>
|
||||||
|
Tag Cloud
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
||||||
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
|
|
||||||
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
||||||
{ value: "date_desc", label: "Newest first" },
|
{ value: "date_desc", label: "Newest first" },
|
||||||
@@ -162,6 +163,7 @@ export function Toolbar() {
|
|||||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||||
|
const activeView = useGalleryStore((state) => state.activeView);
|
||||||
|
|
||||||
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
||||||
|
|
||||||
@@ -269,6 +271,8 @@ export function Toolbar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Search */}
|
{/* 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>;
|
renameFolder: (folderId: number, newName: string) => Promise<void>;
|
||||||
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
|
updateFolderPath: (folderId: number, newPath: string) => Promise<void>;
|
||||||
selectFolder: (folderId: number | null) => void;
|
selectFolder: (folderId: number | null) => void;
|
||||||
|
setViewFolderScope: (folderId: number | null) => void;
|
||||||
loadImages: (reset?: boolean) => Promise<void>;
|
loadImages: (reset?: boolean) => Promise<void>;
|
||||||
loadMoreImages: () => Promise<void>;
|
loadMoreImages: () => Promise<void>;
|
||||||
setSearch: (search: string) => void;
|
setSearch: (search: string) => void;
|
||||||
@@ -755,6 +756,34 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
void get().loadImages(true);
|
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) => {
|
loadImages: async (reset = false) => {
|
||||||
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
|
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
|
||||||
const parsedSearch = parseSearchValue(search);
|
const parsedSearch = parseSearchValue(search);
|
||||||
|
|||||||
Reference in New Issue
Block a user