style: format frontend with prettier

Mechanical one-shot pass of pnpm format over src/, tests/, tools/, and
root configs. No functional changes; build and type-check verified.
This commit is contained in:
2026-07-04 20:23:32 +01:00
parent 32c6ae09d6
commit 827e1a8ecf
152 changed files with 9204 additions and 7445 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test'
/** /**
* Read environment variables from file. * Read environment variables from file.
@@ -76,4 +76,4 @@ export default defineConfig({
// url: 'http://localhost:3000', // url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI, // reuseExistingServer: !process.env.CI,
// }, // },
}); })
+69 -69
View File
@@ -1,100 +1,100 @@
import { useEffect } from "react"; import { useEffect } from 'react'
import { useGalleryStore } from "./store"; import { useGalleryStore } from './store'
import { Sidebar } from "./components/Sidebar"; import { Sidebar } from './components/Sidebar'
import { BackgroundTasks } from "./components/BackgroundTasks"; import { BackgroundTasks } from './components/BackgroundTasks'
import { Toolbar } from "./components/Toolbar"; import { Toolbar } from './components/Toolbar'
import { Gallery } from "./components/Gallery"; import { Gallery } from './components/Gallery'
import { Lightbox } from "./components/Lightbox"; import { Lightbox } from './components/Lightbox'
import { ExploreView } from "./components/ExploreView"; import { ExploreView } from './components/ExploreView'
import { DuplicateFinder } from "./components/DuplicateFinder"; import { DuplicateFinder } from './components/DuplicateFinder'
import { Timeline } from "./components/Timeline"; import { Timeline } from './components/Timeline'
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from './components/TitleBar'
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from './components/SettingsModal'
import { FolderPickerModal } from "./components/FolderPickerModal"; import { FolderPickerModal } from './components/FolderPickerModal'
import { UpdateToast } from "./components/UpdateToast"; import { UpdateToast } from './components/UpdateToast'
import { WhatsNewToast } from "./components/WhatsNewToast"; import { WhatsNewToast } from './components/WhatsNewToast'
import { WhatsNewModal } from "./components/WhatsNewModal"; import { WhatsNewModal } from './components/WhatsNewModal'
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay"; import { OnboardingOverlay } from './components/onboarding/OnboardingOverlay'
import { DemoPanel } from "./components/DemoPanel"; import { DemoPanel } from './components/DemoPanel'
import { initializeNotifications } from "./notifications"; import { initializeNotifications } from './notifications'
export default function App() { export default function App() {
const loadFolders = useGalleryStore((state) => state.loadFolders); const loadFolders = useGalleryStore((state) => state.loadFolders)
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress); const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress)
const loadImages = useGalleryStore((state) => state.loadImages); const loadImages = useGalleryStore((state) => state.loadImages)
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus); const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus)
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus); const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus)
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel); const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel)
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache); const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache)
const loadAlbums = useGalleryStore((state) => state.loadAlbums); const loadAlbums = useGalleryStore((state) => state.loadAlbums)
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds)
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused); const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused)
const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist); const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist)
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress)
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion); const loadAppVersion = useGalleryStore((state) => state.loadAppVersion)
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates)
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus); const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus)
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted); const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted)
const initWhatsNew = useGalleryStore((state) => state.initWhatsNew); const initWhatsNew = useGalleryStore((state) => state.initWhatsNew)
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView)
useEffect(() => { useEffect(() => {
void initializeNotifications(); void initializeNotifications()
void loadMutedFolderIds(); void loadMutedFolderIds()
void loadNotificationsPaused(); void loadNotificationsPaused()
void loadWorkerPausesPersist(); void loadWorkerPausesPersist()
void loadFfmpegStatus(); void loadFfmpegStatus()
void loadOnboardingCompleted(); void loadOnboardingCompleted()
// Load the app version first so the What's New toast/modal (which read // Load the app version first so the What's New toast/modal (which read
// appVersion from the store) have it before the greeting can appear. // appVersion from the store) have it before the greeting can appear.
void loadAppVersion().then(() => initWhatsNew()); void loadAppVersion().then(() => initWhatsNew())
// Quiet launch check — dev builds have no signed artifacts to update to. // Quiet launch check — dev builds have no signed artifacts to update to.
if (import.meta.env.PROD) { if (import.meta.env.PROD) {
void checkForUpdates({ quiet: true }); void checkForUpdates({ quiet: true })
} }
loadFolders().then(async () => { loadFolders().then(async () => {
void loadBackgroundJobProgress(); void loadBackgroundJobProgress()
void loadCaptionModelStatus(); void loadCaptionModelStatus()
void loadTaggerModel(); void loadTaggerModel()
void loadTaggerModelStatus(); void loadTaggerModelStatus()
void loadDuplicateScanCache(); void loadDuplicateScanCache()
await loadAlbums(); await loadAlbums()
await loadImages(true); await loadImages(true)
if (import.meta.env.MODE === "ui") { if (import.meta.env.MODE === 'ui') {
const { applyMockScenario } = await import("./dev/applyMockScenario"); const { applyMockScenario } = await import('./dev/applyMockScenario')
applyMockScenario(); applyMockScenario()
} }
}); })
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined
subscribeToProgress().then((fn) => { subscribeToProgress().then((fn) => {
unlisten = fn; unlisten = fn
}); })
return () => { return () => {
unlisten?.(); unlisten?.()
}; }
}, []); }, [])
return ( return (
<div className="flex h-screen flex-col bg-gray-950 text-white overflow-hidden select-none"> <div className="flex h-screen flex-col overflow-hidden bg-gray-950 text-white select-none">
{/* Custom title bar — sits at the very top */} {/* Custom title bar — sits at the very top */}
<TitleBar /> <TitleBar />
{/* Main app content below the title bar */} {/* Main app content below the title bar */}
<div className="flex flex-1 min-h-0"> <div className="flex min-h-0 flex-1">
<Sidebar /> <Sidebar />
<main className="flex-1 flex flex-col min-w-0"> <main className="flex min-w-0 flex-1 flex-col">
{activeView === "timeline" ? ( {activeView === 'timeline' ? (
<> <>
<Toolbar /> <Toolbar />
<BackgroundTasks /> <BackgroundTasks />
<Timeline /> <Timeline />
</> </>
) : activeView === "explore" ? ( ) : activeView === 'explore' ? (
<> <>
<BackgroundTasks /> <BackgroundTasks />
<ExploreView /> <ExploreView />
</> </>
) : activeView === "duplicates" ? ( ) : activeView === 'duplicates' ? (
<> <>
<BackgroundTasks /> <BackgroundTasks />
<DuplicateFinder /> <DuplicateFinder />
@@ -118,5 +118,5 @@ export default function App() {
<OnboardingOverlay /> <OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />} {import.meta.env.DEV && <DemoPanel />}
</div> </div>
); )
} }
+55 -51
View File
@@ -2,113 +2,117 @@
// data so the "What's New" UI can render it nicely instead of dumping markdown. // data so the "What's New" UI can render it nicely instead of dumping markdown.
// Keeping the changelog as the single source of truth means there's no separate // Keeping the changelog as the single source of truth means there's no separate
// per-release copy to maintain — whatever ships in CHANGELOG.md is what users see. // per-release copy to maintain — whatever ships in CHANGELOG.md is what users see.
import changelogRaw from "../CHANGELOG.md?raw"; import changelogRaw from '../CHANGELOG.md?raw'
export interface ChangelogItem { export interface ChangelogItem {
/** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */ /** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */
lead: string | null; lead: string | null
/** The remaining descriptive text. May still contain inline `code` / **bold** markers. */ /** The remaining descriptive text. May still contain inline `code` / **bold** markers. */
body: string; body: string
} }
export interface ChangelogSection { export interface ChangelogSection {
/** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */ /** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */
title: string; title: string
items: ChangelogItem[]; items: ChangelogItem[]
} }
export interface ChangelogEntry { export interface ChangelogEntry {
version: string; version: string
date: string | null; date: string | null
sections: ChangelogSection[]; sections: ChangelogSection[]
} }
// "## [0.1.1] — 2026-06-23" / "## [Unreleased]" // "## [0.1.1] — 2026-06-23" / "## [Unreleased]"
const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/; const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/
// "### Added" // "### Added"
const SECTION_HEADING = /^###\s+(.+?)\s*$/; const SECTION_HEADING = /^###\s+(.+?)\s*$/
// "- bullet text" // "- bullet text"
const BULLET = /^[-*]\s+(.*)$/; const BULLET = /^[-*]\s+(.*)$/
// Leading "**Title**" optionally followed by an em dash, used as the item's lead-in. // Leading "**Title**" optionally followed by an em dash, used as the item's lead-in.
// (Item text is whitespace-collapsed before matching, so no dotAll flag needed.) // (Item text is whitespace-collapsed before matching, so no dotAll flag needed.)
const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/; const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/
function toItem(text: string): ChangelogItem { function toItem(text: string): ChangelogItem {
const collapsed = text.replace(/\s+/g, " ").trim(); const collapsed = text.replace(/\s+/g, ' ').trim()
const match = collapsed.match(LEAD); const match = collapsed.match(LEAD)
if (match) { if (match) {
return { lead: match[1].trim(), body: match[2].trim() }; return { lead: match[1].trim(), body: match[2].trim() }
} }
return { lead: null, body: collapsed }; return { lead: null, body: collapsed }
} }
function parseChangelog(raw: string): ChangelogEntry[] { function parseChangelog(raw: string): ChangelogEntry[] {
const lines = raw.split(/\r?\n/); const lines = raw.split(/\r?\n/)
const entries: ChangelogEntry[] = []; const entries: ChangelogEntry[] = []
let entry: ChangelogEntry | null = null; let entry: ChangelogEntry | null = null
let section: ChangelogSection | null = null; let section: ChangelogSection | null = null
let buffer: string[] = []; let buffer: string[] = []
const flushItem = () => { const flushItem = () => {
if (section && buffer.length > 0) { if (section && buffer.length > 0) {
section.items.push(toItem(buffer.join(" "))); section.items.push(toItem(buffer.join(' ')))
} }
buffer = []; buffer = []
}; }
for (const line of lines) { for (const line of lines) {
const versionMatch = line.match(VERSION_HEADING); const versionMatch = line.match(VERSION_HEADING)
if (versionMatch) { if (versionMatch) {
flushItem(); flushItem()
section = null; section = null
entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] }; entry = {
entries.push(entry); version: versionMatch[1].trim(),
continue; date: versionMatch[2]?.trim() ?? null,
sections: [],
}
entries.push(entry)
continue
} }
// Stop collecting once we leave the changelog body (e.g. link-reference defs at EOF). // Stop collecting once we leave the changelog body (e.g. link-reference defs at EOF).
if (!entry) continue; if (!entry) continue
const sectionMatch = line.match(SECTION_HEADING); const sectionMatch = line.match(SECTION_HEADING)
if (sectionMatch) { if (sectionMatch) {
flushItem(); flushItem()
section = { title: sectionMatch[1].trim(), items: [] }; section = { title: sectionMatch[1].trim(), items: [] }
entry.sections.push(section); entry.sections.push(section)
continue; continue
} }
const bulletMatch = line.match(BULLET); const bulletMatch = line.match(BULLET)
if (bulletMatch) { if (bulletMatch) {
flushItem(); flushItem()
buffer.push(bulletMatch[1]); buffer.push(bulletMatch[1])
continue; continue
} }
if (line.trim() === "") { if (line.trim() === '') {
// A blank line ends a (possibly wrapped) multi-line bullet. // A blank line ends a (possibly wrapped) multi-line bullet.
flushItem(); flushItem()
continue; continue
} }
// Indented continuation of the current wrapped bullet. // Indented continuation of the current wrapped bullet.
if (buffer.length > 0) { if (buffer.length > 0) {
buffer.push(line.trim()); buffer.push(line.trim())
} }
} }
flushItem(); flushItem()
return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) })); return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) }))
} }
const ENTRIES = parseChangelog(changelogRaw); const ENTRIES = parseChangelog(changelogRaw)
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null { export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
if (!version) return null; if (!version) return null
// Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so // Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so
// dev/UI-lab builds still resolve to the correct changelog entry. // dev/UI-lab builds still resolve to the correct changelog entry.
const normalized = version.replace(/^v/, "").replace(/-[a-z].*/i, ""); const normalized = version.replace(/^v/, '').replace(/-[a-z].*/i, '')
// Never surface the in-progress [Unreleased] section to users. // Never surface the in-progress [Unreleased] section to users.
if (normalized.toLowerCase() === "unreleased") return null; if (normalized.toLowerCase() === 'unreleased') return null
return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null; return ENTRIES.find((e) => e.version.replace(/^v/, '') === normalized) ?? null
} }
+17 -17
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from 'react'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
/** /**
* Album list plus a create-new-album form. The host decides what picking * Album list plus a create-new-album form. The host decides what picking
@@ -7,23 +7,23 @@ import { useGalleryStore } from "../store";
* picked immediately. * picked immediately.
*/ */
export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise<void> | void }) { export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise<void> | void }) {
const albums = useGalleryStore((state) => state.albums); const albums = useGalleryStore((state) => state.albums)
const createAlbum = useGalleryStore((state) => state.createAlbum); const createAlbum = useGalleryStore((state) => state.createAlbum)
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false)
const [newAlbumName, setNewAlbumName] = useState(""); const [newAlbumName, setNewAlbumName] = useState('')
const handleCreate = async () => { const handleCreate = async () => {
const name = newAlbumName.trim(); const name = newAlbumName.trim()
if (!name || creating) return; if (!name || creating) return
setCreating(true); setCreating(true)
try { try {
const album = await createAlbum(name); const album = await createAlbum(name)
setNewAlbumName(""); setNewAlbumName('')
await onPick(album.id); await onPick(album.id)
} finally { } finally {
setCreating(false); setCreating(false)
} }
}; }
return ( return (
<> <>
@@ -46,8 +46,8 @@ export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise<v
<form <form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2" className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault()
void handleCreate(); void handleCreate()
}} }}
> >
<input <input
@@ -66,5 +66,5 @@ export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise<v
</button> </button>
</form> </form>
</> </>
); )
} }
+72 -56
View File
@@ -1,68 +1,83 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react'
import { invoke } from "@tauri-apps/api/core"; import { invoke } from '@tauri-apps/api/core'
import { useGalleryStore, type WorkerKey } from "../store"; import { useGalleryStore, type WorkerKey } from '../store'
import { BackgroundTaskSummary } from "./backgroundTasks/BackgroundTaskSummary"; import { BackgroundTaskSummary } from './backgroundTasks/BackgroundTaskSummary'
import { ExpandedTaskPanel } from "./backgroundTasks/ExpandedTaskPanel"; import { ExpandedTaskPanel } from './backgroundTasks/ExpandedTaskPanel'
import { buildDuplicateScanTask, buildFolderTasks, taskHasTerminalFailure, taskProgress } from "./backgroundTasks/taskModel"; import {
import type { BackgroundTask, FailedWorkerItem } from "./backgroundTasks/types"; buildDuplicateScanTask,
buildFolderTasks,
taskHasTerminalFailure,
taskProgress,
} from './backgroundTasks/taskModel'
import type { BackgroundTask, FailedWorkerItem } from './backgroundTasks/types'
export function BackgroundTasks() { export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders)
const indexingProgress = useGalleryStore((state) => state.indexingProgress); const indexingProgress = useGalleryStore((state) => state.indexingProgress)
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress)
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings)
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs)
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging); const showFailedTagging = useGalleryStore((state) => state.showFailedTagging)
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning)
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress)
const workerPaused = useGalleryStore((state) => state.workerPaused); const workerPaused = useGalleryStore((state) => state.workerPaused)
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates)
const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused); const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused)
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false)
const [dismissed, setDismissed] = useState<Record<number, string>>({}); const [dismissed, setDismissed] = useState<Record<number, string>>({})
const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<Record<number, FailedWorkerItem[]>>({}); const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>({}); Record<number, FailedWorkerItem[]>
>({})
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>(
{}
)
useEffect(() => { useEffect(() => {
void loadWorkerStates(); void loadWorkerStates()
}, [folders, loadWorkerStates]); }, [folders, loadWorkerStates])
const failedEmbeddingCounts = useMemo( const failedEmbeddingCounts = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, progress]) => [id, progress?.embedding_failed ?? 0]), Object.entries(mediaJobProgress).map(([id, progress]) => [
id,
progress?.embedding_failed ?? 0,
])
), ),
[mediaJobProgress], [mediaJobProgress]
); )
const failedTaggingCounts = useMemo( const failedTaggingCounts = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, progress]) => [id, progress?.tagging_failed ?? 0]), Object.entries(mediaJobProgress).map(([id, progress]) => [
id,
progress?.tagging_failed ?? 0,
])
), ),
[mediaJobProgress], [mediaJobProgress]
); )
useEffect(() => { useEffect(() => {
if (!expanded) return; if (!expanded) return
for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) { for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
if (count > 0) { if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_embedding_images", { invoke<FailedWorkerItem[]>('get_failed_embedding_images', {
folderId: Number(folderId), folderId: Number(folderId),
}) })
.then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items }))) .then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined); .catch(() => undefined)
} }
} }
for (const [folderId, count] of Object.entries(failedTaggingCounts)) { for (const [folderId, count] of Object.entries(failedTaggingCounts)) {
if (count > 0) { if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_tagging_images", { invoke<FailedWorkerItem[]>('get_failed_tagging_images', {
folderId: Number(folderId), folderId: Number(folderId),
}) })
.then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items }))) .then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined); .catch(() => undefined)
} }
} }
}, [expanded, failedEmbeddingCounts, failedTaggingCounts]); }, [expanded, failedEmbeddingCounts, failedTaggingCounts])
const folderTasks = useMemo( const folderTasks = useMemo(
() => () =>
@@ -73,37 +88,38 @@ export function BackgroundTasks() {
mediaJobProgress, mediaJobProgress,
workerPaused, workerPaused,
}), }),
[dismissed, folders, indexingProgress, mediaJobProgress, workerPaused], [dismissed, folders, indexingProgress, mediaJobProgress, workerPaused]
); )
const duplicateScanTask = useMemo( const duplicateScanTask = useMemo(
() => buildDuplicateScanTask(duplicateScanning, duplicateScanProgress), () => buildDuplicateScanTask(duplicateScanning, duplicateScanProgress),
[duplicateScanning, duplicateScanProgress], [duplicateScanning, duplicateScanProgress]
); )
const allTasks = duplicateScanTask ? [duplicateScanTask, ...folderTasks] : folderTasks; const allTasks = duplicateScanTask ? [duplicateScanTask, ...folderTasks] : folderTasks
if (allTasks.length === 0) return null; if (allTasks.length === 0) return null
const isWorkerPaused = (folderId: number, worker: WorkerKey) => workerPaused[folderId]?.[worker] ?? false; const isWorkerPaused = (folderId: number, worker: WorkerKey) =>
workerPaused[folderId]?.[worker] ?? false
const toggleWorker = (folderId: number, worker: WorkerKey) => { const toggleWorker = (folderId: number, worker: WorkerKey) => {
setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker)); setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker))
}; }
const dismissTask = (task: BackgroundTask) => { const dismissTask = (task: BackgroundTask) => {
if (task.id < 0) return; if (task.id < 0) return
setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot })); setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot }))
setExpanded(false); setExpanded(false)
}; }
const retryTask = (task: BackgroundTask) => { const retryTask = (task: BackgroundTask) => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id); if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id)
if (task.hasFailedTagging) void queueTaggingJobs(task.id); if (task.hasFailedTagging) void queueTaggingJobs(task.id)
}; }
const primary = allTasks[0]; const primary = allTasks[0]
const hasFailed = folderTasks.some(taskHasTerminalFailure); const hasFailed = folderTasks.some(taskHasTerminalFailure)
const barProgress = taskProgress(primary); const barProgress = taskProgress(primary)
return ( return (
<div className="shrink-0 border-b border-white/[0.06]"> <div className="shrink-0 border-b border-white/[0.06]">
@@ -135,5 +151,5 @@ export function BackgroundTasks() {
/> />
) : null} ) : null}
</div> </div>
); )
} }
+68 -56
View File
@@ -1,66 +1,67 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { BulkAlbumPopover } from "./bulk/BulkAlbumPopover"; import { BulkAlbumPopover } from './bulk/BulkAlbumPopover'
import { BulkDeleteConfirm } from "./bulk/BulkDeleteConfirm"; import { BulkDeleteConfirm } from './bulk/BulkDeleteConfirm'
import { BulkRatingPopover } from "./bulk/BulkRatingPopover"; import { BulkRatingPopover } from './bulk/BulkRatingPopover'
import { BulkSelectionSummary } from "./bulk/BulkSelectionSummary"; import { BulkSelectionSummary } from './bulk/BulkSelectionSummary'
import { BulkTagPopover } from "./bulk/BulkTagPopover"; import { BulkTagPopover } from './bulk/BulkTagPopover'
import { type BulkPanel } from "./bulk/types"; import { type BulkPanel } from './bulk/types'
import { useDismissable } from "./menu"; import { useDismissable } from './menu'
import { Tooltip } from "./Tooltip"; import { Tooltip } from './Tooltip'
import { CloseIcon } from "./icons"; import { CloseIcon } from './icons'
export function BulkActionBar() { export function BulkActionBar() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size); const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size)
const selectedIds = useGalleryStore((state) => state.gallerySelectedIds); const selectedIds = useGalleryStore((state) => state.gallerySelectedIds)
const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection); const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection)
const selectAllGallery = useGalleryStore((state) => state.selectAllGallery); const selectAllGallery = useGalleryStore((state) => state.selectAllGallery)
const loadedCount = useGalleryStore((state) => state.loadedCount); const loadedCount = useGalleryStore((state) => state.loadedCount)
const totalImages = useGalleryStore((state) => state.totalImages); const totalImages = useGalleryStore((state) => state.totalImages)
const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite); const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite)
const bulkSetRating = useGalleryStore((state) => state.bulkSetRating); const bulkSetRating = useGalleryStore((state) => state.bulkSetRating)
const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected); const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected)
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView)
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId); const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId)
const addToAlbum = useGalleryStore((state) => state.addToAlbum); const addToAlbum = useGalleryStore((state) => state.addToAlbum)
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum); const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum)
const [panel, setPanel] = useState<BulkPanel>(null); const [panel, setPanel] = useState<BulkPanel>(null)
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false)
const barRef = useRef<HTMLDivElement>(null); const barRef = useRef<HTMLDivElement>(null)
// Close any open popover when clicking outside the bar or pressing Escape. // Close any open popover when clicking outside the bar or pressing Escape.
useDismissable(barRef, () => setPanel(null), panel !== null); useDismissable(barRef, () => setPanel(null), panel !== null)
// Reset transient UI whenever the selection empties. // Reset transient UI whenever the selection empties.
useEffect(() => { useEffect(() => {
if (selectedCount === 0) setPanel(null); if (selectedCount === 0) setPanel(null)
}, [selectedCount]); }, [selectedCount])
if (selectedCount === 0) return null; if (selectedCount === 0) return null
const ids = Array.from(selectedIds); const ids = Array.from(selectedIds)
const inAlbumView = activeView === "album" && selectedAlbumId !== null; const inAlbumView = activeView === 'album' && selectedAlbumId !== null
const togglePanel = (next: Exclude<BulkPanel, null>) => setPanel((current) => (current === next ? null : next)); const togglePanel = (next: Exclude<BulkPanel, null>) =>
setPanel((current) => (current === next ? null : next))
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true); setDeleting(true)
try { try {
await bulkDeleteSelected(); await bulkDeleteSelected()
} finally { } finally {
setDeleting(false); setDeleting(false)
setPanel(null); setPanel(null)
} }
}; }
const handlePickAlbum = async (albumId: number) => { const handlePickAlbum = async (albumId: number) => {
await addToAlbum(albumId, ids); await addToAlbum(albumId, ids)
setPanel(null); setPanel(null)
}; }
const btn = "rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors"; const btn = 'rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors'
const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`; const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`
const btnActive = `${btn} bg-white/10 text-white`; const btnActive = `${btn} bg-white/10 text-white`
return ( return (
<div <div
@@ -78,17 +79,25 @@ export function BulkActionBar() {
<div className="h-5 w-px bg-white/10" /> <div className="h-5 w-px bg-white/10" />
<div className="relative"> <div className="relative">
<button className={panel === "tag" ? btnActive : btnIdle} onClick={() => togglePanel("tag")}> <button
className={panel === 'tag' ? btnActive : btnIdle}
onClick={() => togglePanel('tag')}
>
Tag Tag
</button> </button>
{panel === "tag" ? <BulkTagPopover onClose={() => setPanel(null)} /> : null} {panel === 'tag' ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
</div> </div>
<div className="relative"> <div className="relative">
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}> <button
className={panel === 'rating' ? btnActive : btnIdle}
onClick={() => togglePanel('rating')}
>
Rating Rating
</button> </button>
{panel === "rating" ? <BulkRatingPopover onSetRating={bulkSetRating} onClose={() => setPanel(null)} /> : null} {panel === 'rating' ? (
<BulkRatingPopover onSetRating={bulkSetRating} onClose={() => setPanel(null)} />
) : null}
</div> </div>
<Tooltip label="Mark as favorite" followCursor> <Tooltip label="Mark as favorite" followCursor>
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)}> <button className={btnIdle} onClick={() => void bulkSetFavorite(true)}>
@@ -96,10 +105,13 @@ export function BulkActionBar() {
</button> </button>
</Tooltip> </Tooltip>
<div className="relative"> <div className="relative">
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}> <button
className={panel === 'album' ? btnActive : btnIdle}
onClick={() => togglePanel('album')}
>
Add to album Add to album
</button> </button>
{panel === "album" ? <BulkAlbumPopover onPick={handlePickAlbum} /> : null} {panel === 'album' ? <BulkAlbumPopover onPick={handlePickAlbum} /> : null}
</div> </div>
{inAlbumView ? ( {inAlbumView ? (
@@ -117,17 +129,17 @@ export function BulkActionBar() {
<Tooltip label="Delete files from disk" followCursor> <Tooltip label="Delete files from disk" followCursor>
<button <button
className={ className={
panel === "delete" panel === 'delete'
? `${btn} bg-red-500/15 text-red-300` ? `${btn} bg-red-500/15 text-red-300`
: `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`
} }
onClick={() => togglePanel("delete")} onClick={() => togglePanel('delete')}
disabled={deleting} disabled={deleting}
> >
{deleting ? "Deleting…" : "Delete"} {deleting ? 'Deleting…' : 'Delete'}
</button> </button>
</Tooltip> </Tooltip>
{panel === "delete" ? ( {panel === 'delete' ? (
<BulkDeleteConfirm <BulkDeleteConfirm
deleting={deleting} deleting={deleting}
selectedCount={selectedCount} selectedCount={selectedCount}
@@ -145,5 +157,5 @@ export function BulkActionBar() {
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
); )
} }
+107 -81
View File
@@ -1,69 +1,82 @@
import { useRef, useState } from "react"; import { useRef, useState } from 'react'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { useDismissable } from "./menu"; import { useDismissable } from './menu'
import { Tooltip } from "./Tooltip"; import { Tooltip } from './Tooltip'
type Rgb = [number, number, number]; type Rgb = [number, number, number]
// Representative colors for the quick-pick swatches. Each is just an RGB the // Representative colors for the quick-pick swatches. Each is just an RGB the
// distance filter matches against — not a hard bucket. // distance filter matches against — not a hard bucket.
const SWATCHES: { name: string; rgb: Rgb }[] = [ const SWATCHES: { name: string; rgb: Rgb }[] = [
{ name: "Red", rgb: [226, 59, 59] }, { name: 'Red', rgb: [226, 59, 59] },
{ name: "Orange", rgb: [232, 134, 46] }, { name: 'Orange', rgb: [232, 134, 46] },
{ name: "Yellow", rgb: [242, 207, 46] }, { name: 'Yellow', rgb: [242, 207, 46] },
{ name: "Green", rgb: [76, 175, 80] }, { name: 'Green', rgb: [76, 175, 80] },
{ name: "Teal", rgb: [31, 182, 166] }, { name: 'Teal', rgb: [31, 182, 166] },
{ name: "Blue", rgb: [59, 125, 216] }, { name: 'Blue', rgb: [59, 125, 216] },
{ name: "Purple", rgb: [139, 92, 246] }, { name: 'Purple', rgb: [139, 92, 246] },
{ name: "Pink", rgb: [236, 72, 153] }, { name: 'Pink', rgb: [236, 72, 153] },
{ name: "Brown", rgb: [139, 90, 43] }, { name: 'Brown', rgb: [139, 90, 43] },
{ name: "Black", rgb: [26, 26, 26] }, { name: 'Black', rgb: [26, 26, 26] },
{ name: "White", rgb: [245, 245, 245] }, { name: 'White', rgb: [245, 245, 245] },
{ name: "Gray", rgb: [154, 160, 166] }, { name: 'Gray', rgb: [154, 160, 166] },
]; ]
function rgbEquals(a: Rgb | null, b: Rgb): boolean { function rgbEquals(a: Rgb | null, b: Rgb): boolean {
return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
} }
function toHex([r, g, b]: Rgb): string { function toHex([r, g, b]: Rgb): string {
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`; return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}`
} }
function fromHex(hex: string): Rgb { function fromHex(hex: string): Rgb {
const n = parseInt(hex.slice(1), 16); const n = parseInt(hex.slice(1), 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
} }
export function ColorFilter() { export function ColorFilter() {
const colorFilter = useGalleryStore((state) => state.colorFilter); const colorFilter = useGalleryStore((state) => state.colorFilter)
const setColorFilter = useGalleryStore((state) => state.setColorFilter); const setColorFilter = useGalleryStore((state) => state.setColorFilter)
const colorBackfill = useGalleryStore((state) => state.colorBackfill); const colorBackfill = useGalleryStore((state) => state.colorBackfill)
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null)
const isActive = colorFilter !== null; const isActive = colorFilter !== null
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb)); const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb))
// Collapse the panel when clicking elsewhere or pressing Escape. // Collapse the panel when clicking elsewhere or pressing Escape.
useDismissable(ref, () => setOpen(false), open); useDismissable(ref, () => setOpen(false), open)
return ( return (
<div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/6 pl-2"> <div
ref={ref}
className="relative ml-1 flex shrink-0 items-center border-l border-white/6 pl-2"
>
{/* Trigger — a single palette icon; shows the active color as a dot when a {/* Trigger — a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */} filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400} anchorToCursor> <Tooltip
label={isActive ? 'Color filter active' : 'Filter by color'}
delay={400}
anchorToCursor
>
<button <button
className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${ className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${
open || isActive ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/5 hover:text-gray-200" open || isActive
? 'bg-white/10 text-white'
: 'text-gray-500 hover:bg-white/5 hover:text-gray-200'
}`} }`}
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => !value)}
aria-label="Filter by color" aria-label="Filter by color"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} <path
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z" /> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.6}
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z"
/>
<circle cx="7.5" cy="11.5" r="1" fill="currentColor" stroke="none" /> <circle cx="7.5" cy="11.5" r="1" fill="currentColor" stroke="none" />
<circle cx="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" /> <circle cx="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" /> <circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
@@ -86,65 +99,78 @@ export function ColorFilter() {
initial={{ opacity: 0, y: -4, scale: 0.98 }} initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }} exit={{ opacity: 0, y: -4, scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }} transition={{ duration: 0.14, ease: 'easeOut' }}
className="absolute right-0 top-full z-30 mt-2 w-max rounded-xl border border-white/10 bg-gray-950/98 p-2.5 shadow-2xl backdrop-blur light-theme:border-gray-700/50" className="light-theme:border-gray-700/50 absolute top-full right-0 z-30 mt-2 w-max rounded-xl border border-white/10 bg-gray-950/98 p-2.5 shadow-2xl backdrop-blur"
> >
<div className="grid grid-cols-7 gap-1.5"> <div className="grid grid-cols-7 gap-1.5">
{SWATCHES.map((swatch) => { {SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb); const active = rgbEquals(colorFilter, swatch.rgb)
return ( return (
<Tooltip label= {swatch.name} followCursor> <Tooltip label={swatch.name} followCursor>
<button <button
key={swatch.name} key={swatch.name}
aria-label={`Filter by ${swatch.name}`} aria-label={`Filter by ${swatch.name}`}
className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${ className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110" active
}`} ? 'scale-110 border-white/40 ring-2 ring-white/70'
style={{ backgroundColor: toHex(swatch.rgb) }} : 'border-white/15 hover:scale-110'
onClick={() => setColorFilter(active ? null : swatch.rgb)} }`}
/> style={{ backgroundColor: toHex(swatch.rgb) }}
</Tooltip> onClick={() => setColorFilter(active ? null : swatch.rgb)}
); />
</Tooltip>
)
})} })}
<Tooltip label= "Custom Colour" followCursor> <Tooltip label="Custom Colour" followCursor>
{/* Custom color picker — rainbow until a custom color is chosen. */} {/* Custom color picker — rainbow until a custom color is chosen. */}
<label <label
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${ className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110" isCustom
}`} ? 'border-white/40 ring-2 ring-white/70'
style={ : 'border-white/15 hover:scale-110'
isCustom }`}
? { backgroundColor: toHex(colorFilter as Rgb) } style={
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" } isCustom
} ? { backgroundColor: toHex(colorFilter as Rgb) }
> : {
<input background:
type="color" 'conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)',
className="absolute inset-0 cursor-pointer opacity-0" }
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"} }
onChange={(event) => setColorFilter(fromHex(event.target.value))} >
/> <input
</label> type="color"
className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : '#3b7dd8'}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/>
</label>
</Tooltip> </Tooltip>
</div> </div>
{isActive || (colorBackfill && colorBackfill.total > 0) ? ( {isActive || (colorBackfill && colorBackfill.total > 0) ? (
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2 light-theme:border-gray-700/40"> <div className="light-theme:border-gray-700/40 mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2">
{colorBackfill && colorBackfill.total > 0 ? ( {colorBackfill && colorBackfill.total > 0 ? (
<Tooltip label="Sampling colours from existing thumbnails — colour search fills in as this runs" anchorToCursor> <Tooltip
label="Sampling colours from existing thumbnails — colour search fills in as this runs"
anchorToCursor
>
<span className="text-[10px] text-gray-600"> <span className="text-[10px] text-gray-600">
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()} sampling {colorBackfill.processed.toLocaleString()}/
{colorBackfill.total.toLocaleString()}
</span> </span>
</Tooltip> </Tooltip>
) : <span />} ) : (
<span />
)}
{isActive ? ( {isActive ? (
<Tooltip label="Clear colour filter" anchorToCursor> <Tooltip label="Clear colour filter" anchorToCursor>
<button <button
className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200" className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
onClick={() => setColorFilter(null)} onClick={() => setColorFilter(null)}
> >
Clear Clear
</button> </button>
</Tooltip> </Tooltip>
) : null} ) : null}
</div> </div>
@@ -153,5 +179,5 @@ export function ColorFilter() {
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
</div> </div>
); )
} }
+83 -73
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react'
import { FolderJobProgress, useGalleryStore } from "../store"; import { FolderJobProgress, useGalleryStore } from '../store'
// Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise // Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise
// too transient (or unreachable) to capture: the background-worker pipeline, // too transient (or unreachable) to capture: the background-worker pipeline,
@@ -25,7 +25,7 @@ function emptyProgress(folderId: number): FolderJobProgress {
tagging_pending: 0, tagging_pending: 0,
tagging_ready: 0, tagging_ready: 0,
tagging_failed: 0, tagging_failed: 0,
}; }
} }
// A believable multi-folder "busy pipeline" — three folders at different stages. // A believable multi-folder "busy pipeline" — three folders at different stages.
@@ -36,56 +36,56 @@ const BUSY_PRESETS: Partial<FolderJobProgress>[] = [
{ thumbnail_pending: 11, embedding_pending: 50, tagging_pending: 50 }, { thumbnail_pending: 11, embedding_pending: 50, tagging_pending: 50 },
// Late stage: embeddings done, tagging running. // Late stage: embeddings done, tagging running.
{ embedding_ready: 40, tagging_ready: 18, tagging_pending: 22 }, { embedding_ready: 40, tagging_ready: 18, tagging_pending: 22 },
]; ]
const DEMO_UPDATE_VERSION = "0.2.0"; const DEMO_UPDATE_VERSION = '0.2.0'
export function DemoPanel() { export function DemoPanel() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders)
const appVersion = useGalleryStore((state) => state.appVersion); const appVersion = useGalleryStore((state) => state.appVersion)
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
const downloadTimer = useRef<number | null>(null); const downloadTimer = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "d") { if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'd') {
event.preventDefault(); event.preventDefault()
setOpen((value) => !value); setOpen((value) => !value)
} }
}; }
window.addEventListener("keydown", onKey); window.addEventListener('keydown', onKey)
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener('keydown', onKey)
}, []); }, [])
const stopTimer = () => { const stopTimer = () => {
if (downloadTimer.current !== null) { if (downloadTimer.current !== null) {
window.clearInterval(downloadTimer.current); window.clearInterval(downloadTimer.current)
downloadTimer.current = null; downloadTimer.current = null
} }
}; }
// Stop any running download animation when the panel unmounts. // Stop any running download animation when the panel unmounts.
useEffect(() => stopTimer, []); useEffect(() => stopTimer, [])
const injectBusy = () => { const injectBusy = () => {
const progress: Record<number, FolderJobProgress> = {}; const progress: Record<number, FolderJobProgress> = {}
folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => { folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => {
progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] }; progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] }
}); })
useGalleryStore.setState({ mediaJobProgress: progress }); useGalleryStore.setState({ mediaJobProgress: progress })
}; }
const injectSingleEmbedding = () => { const injectSingleEmbedding = () => {
const folder = folders[0]; const folder = folders[0]
if (!folder) return; if (!folder) return
useGalleryStore.setState({ useGalleryStore.setState({
mediaJobProgress: { mediaJobProgress: {
[folder.id]: { ...emptyProgress(folder.id), embedding_ready: 27, embedding_pending: 23 }, [folder.id]: { ...emptyProgress(folder.id), embedding_ready: 27, embedding_pending: 23 },
}, },
}); })
}; }
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} }); const clear = () => useGalleryStore.setState({ mediaJobProgress: {} })
// --- Updater flow --------------------------------------------------------- // --- Updater flow ---------------------------------------------------------
// These drive the real UpdateToast + Settings "About" row. The Install button // These drive the real UpdateToast + Settings "About" row. The Install button
@@ -93,73 +93,73 @@ export function DemoPanel() {
// presentation only — see DemoPanel's header note for the real e2e path. // presentation only — see DemoPanel's header note for the real e2e path.
const updateAvailable = () => { const updateAvailable = () => {
stopTimer(); stopTimer()
useGalleryStore.setState({ useGalleryStore.setState({
updateStatus: "available", updateStatus: 'available',
updateVersion: DEMO_UPDATE_VERSION, updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null, updateProgress: null,
updateError: null, updateError: null,
updateDismissed: false, updateDismissed: false,
}); })
}; }
// Indeterminate pulse, then climb 0 → 100%, then flip to "installing". // Indeterminate pulse, then climb 0 → 100%, then flip to "installing".
const simulateDownload = () => { const simulateDownload = () => {
stopTimer(); stopTimer()
useGalleryStore.setState({ useGalleryStore.setState({
updateStatus: "downloading", updateStatus: 'downloading',
updateVersion: DEMO_UPDATE_VERSION, updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null, updateProgress: null,
updateError: null, updateError: null,
updateDismissed: false, updateDismissed: false,
}); })
let progress = 0; let progress = 0
window.setTimeout(() => { window.setTimeout(() => {
downloadTimer.current = window.setInterval(() => { downloadTimer.current = window.setInterval(() => {
progress = Math.min(progress + 0.07, 1); progress = Math.min(progress + 0.07, 1)
useGalleryStore.setState({ updateProgress: progress }); useGalleryStore.setState({ updateProgress: progress })
if (progress >= 1) { if (progress >= 1) {
stopTimer(); stopTimer()
useGalleryStore.setState({ updateStatus: "installing" }); useGalleryStore.setState({ updateStatus: 'installing' })
} }
}, 200); }, 200)
}, 700); }, 700)
}; }
const updateInstalling = () => { const updateInstalling = () => {
stopTimer(); stopTimer()
useGalleryStore.setState({ useGalleryStore.setState({
updateStatus: "installing", updateStatus: 'installing',
updateVersion: DEMO_UPDATE_VERSION, updateVersion: DEMO_UPDATE_VERSION,
updateProgress: 1, updateProgress: 1,
updateDismissed: false, updateDismissed: false,
}); })
}; }
const updateError = () => { const updateError = () => {
stopTimer(); stopTimer()
useGalleryStore.setState({ useGalleryStore.setState({
updateStatus: "error", updateStatus: 'error',
updateError: "Could not reach the update server (connection timed out).", updateError: 'Could not reach the update server (connection timed out).',
updateDismissed: false, updateDismissed: false,
}); })
}; }
const updateUpToDate = () => { const updateUpToDate = () => {
stopTimer(); stopTimer()
useGalleryStore.setState({ updateStatus: "upToDate", updateVersion: null, updateError: null }); useGalleryStore.setState({ updateStatus: 'upToDate', updateVersion: null, updateError: null })
}; }
const resetUpdate = () => { const resetUpdate = () => {
stopTimer(); stopTimer()
useGalleryStore.setState({ useGalleryStore.setState({
updateStatus: "idle", updateStatus: 'idle',
updateVersion: null, updateVersion: null,
updateProgress: null, updateProgress: null,
updateError: null, updateError: null,
updateDismissed: false, updateDismissed: false,
}); })
}; }
// --- What's New flow ------------------------------------------------------ // --- What's New flow ------------------------------------------------------
// Drives the post-update greeting without needing a real version change. The // Drives the post-update greeting without needing a real version change. The
@@ -167,26 +167,32 @@ export function DemoPanel() {
// app version, so the current release's notes are what render. // app version, so the current release's notes are what render.
const showWhatsNewToast = () => const showWhatsNewToast = () =>
useGalleryStore.setState({ whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION, whatsNewOpen: false }); useGalleryStore.setState({
whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION,
whatsNewOpen: false,
})
const openWhatsNewModal = () => useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null }); const openWhatsNewModal = () =>
useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null })
const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null }); const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null })
if (!open) return null; if (!open) return null
const injectBtn = const injectBtn =
"rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25"; 'rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25'
const neutralBtn = const neutralBtn =
"rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10"; 'rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10'
return ( return (
<div className="fixed bottom-4 left-4 z-[100] max-h-[calc(100vh-2rem)] w-56 overflow-y-auto rounded-lg border border-amber-400/40 bg-amber-950/90 p-3 text-xs text-amber-100 shadow-xl backdrop-blur"> <div className="fixed bottom-4 left-4 z-[100] max-h-[calc(100vh-2rem)] w-56 overflow-y-auto rounded-lg border border-amber-400/40 bg-amber-950/90 p-3 text-xs text-amber-100 shadow-xl backdrop-blur">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="font-semibold uppercase tracking-wide">Demo · Ctrl+Shift+D</span> <span className="font-semibold tracking-wide uppercase">Demo · Ctrl+Shift+D</span>
</div> </div>
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Pipeline</p> <p className="mb-1.5 text-[11px] font-semibold tracking-wide text-amber-200/60 uppercase">
Pipeline
</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70"> <p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Inject a frozen worker-bar state, hide this panel, then screenshot. Inject a frozen worker-bar state, hide this panel, then screenshot.
</p> </p>
@@ -204,7 +210,9 @@ export function DemoPanel() {
<div className="my-3 h-px bg-amber-400/20" /> <div className="my-3 h-px bg-amber-400/20" />
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Update flow</p> <p className="mb-1.5 text-[11px] font-semibold tracking-wide text-amber-200/60 uppercase">
Update flow
</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70"> <p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Drives the real toast (bottom-right) + Settings About row. Install is inert here. Drives the real toast (bottom-right) + Settings About row. Install is inert here.
</p> </p>
@@ -231,9 +239,11 @@ export function DemoPanel() {
<div className="my-3 h-px bg-amber-400/20" /> <div className="my-3 h-px bg-amber-400/20" />
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">What's new</p> <p className="mb-1.5 text-[11px] font-semibold tracking-wide text-amber-200/60 uppercase">
What's new
</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70"> <p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Post-update greeting for v{appVersion ?? "—"}, sourced from the bundled changelog. Post-update greeting for v{appVersion ?? ''}, sourced from the bundled changelog.
</p> </p>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={showWhatsNewToast}> <button className={injectBtn} onClick={showWhatsNewToast}>
@@ -247,5 +257,5 @@ export function DemoPanel() {
</button> </button>
</div> </div>
</div> </div>
); )
} }
+52 -46
View File
@@ -1,58 +1,65 @@
import { useRef, useState } from "react"; import { useRef, useState } from 'react'
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from '@tanstack/react-virtual'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { DuplicateScanEmptyState, DuplicateScanIntroState, DuplicateScanLoadingState } from "./duplicateFinder/DuplicateFinderEmptyStates"; import {
import { DuplicateFinderHeader } from "./duplicateFinder/DuplicateFinderHeader"; DuplicateScanEmptyState,
import { DuplicateGroupCard } from "./duplicateFinder/DuplicateGroupCard"; DuplicateScanIntroState,
import { duplicateProgressLabel } from "./duplicateFinder/format"; DuplicateScanLoadingState,
} from './duplicateFinder/DuplicateFinderEmptyStates'
import { DuplicateFinderHeader } from './duplicateFinder/DuplicateFinderHeader'
import { DuplicateGroupCard } from './duplicateFinder/DuplicateGroupCard'
import { duplicateProgressLabel } from './duplicateFinder/format'
export function DuplicateFinder() { export function DuplicateFinder() {
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups); const duplicateGroups = useGalleryStore((state) => state.duplicateGroups)
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning)
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress)
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError); const duplicateScanError = useGalleryStore((state) => state.duplicateScanError)
const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning); const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning)
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds); const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds)
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned); const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates); const scanDuplicates = useGalleryStore((state) => state.scanDuplicates)
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection); const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection)
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups); const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups)
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates); const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates)
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleteResult, setDeleteResult] = useState<string | null>(null); const [deleteResult, setDeleteResult] = useState<string | null>(null)
// Virtualize the group list so a large result set (e.g. thousands of pairs) // Virtualize the group list so a large result set (e.g. thousands of pairs)
// only mounts the on-screen cards. Group cards vary in height (number of // only mounts the on-screen cards. Group cards vary in height (number of
// copies wraps across rows), so heights are measured dynamically. // copies wraps across rows), so heights are measured dynamically.
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: duplicateGroups.length, count: duplicateGroups.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 220, estimateSize: () => 220,
overscan: 4, overscan: 4,
}); })
const selectedCount = duplicateSelectedIds.size; const selectedCount = duplicateSelectedIds.size
const hasResults = duplicateGroups.length > 0; const hasResults = duplicateGroups.length > 0
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null); const hasScanned =
hasResults ||
duplicateLastScanned !== null ||
(!duplicateScanning && duplicateScanProgress !== null)
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true); setDeleting(true)
setConfirmingDelete(false); setConfirmingDelete(false)
setDeleteResult(null); setDeleteResult(null)
try { try {
const deleted = await deleteSelectedDuplicates(); const deleted = await deleteSelectedDuplicates()
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`); setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? '' : 's'}.`)
} catch (e) { } catch (e) {
setDeleteResult(String(e)); setDeleteResult(String(e))
} finally { } finally {
setDeleting(false); setDeleting(false)
} }
}; }
const progressLabel = duplicateProgressLabel(duplicateScanProgress); const progressLabel = duplicateProgressLabel(duplicateScanProgress)
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-gray-950"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-gray-950">
@@ -72,8 +79,8 @@ export function DuplicateFinder() {
onConfirmDelete={() => setConfirmingDelete(false)} onConfirmDelete={() => setConfirmingDelete(false)}
onDelete={handleDelete} onDelete={handleDelete}
onScan={() => { onScan={() => {
setDeleteResult(null); setDeleteResult(null)
void scanDuplicates(selectedFolderId); void scanDuplicates(selectedFolderId)
}} }}
onSelectKeepFirstAll={selectKeepFirstAllGroups} onSelectKeepFirstAll={selectKeepFirstAllGroups}
selectedCount={selectedCount} selectedCount={selectedCount}
@@ -88,32 +95,31 @@ export function DuplicateFinder() {
<DuplicateScanEmptyState /> <DuplicateScanEmptyState />
) : ( ) : (
<div ref={scrollRef} className="overflow-y-auto px-6 py-5"> <div ref={scrollRef} className="overflow-y-auto px-6 py-5">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}> <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => { {virtualizer.getVirtualItems().map((virtualItem) => {
const group = duplicateGroups[virtualItem.index]; const group = duplicateGroups[virtualItem.index]
if (!group) return null; if (!group) return null
return ( return (
<div <div
key={group.file_hash} key={group.file_hash}
data-index={virtualItem.index} data-index={virtualItem.index}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
style={{ style={{
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: "100%", width: '100%',
transform: `translateY(${virtualItem.start}px)`, transform: `translateY(${virtualItem.start}px)`,
paddingBottom: 16, paddingBottom: 16,
}} }}
> >
<DuplicateGroupCard group={group} /> <DuplicateGroupCard group={group} />
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
)} )}
</div> </div>
); )
} }
+77 -63
View File
@@ -1,51 +1,55 @@
import { useEffect } from "react"; import { useEffect } from 'react'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from './FolderScopeDropdown'
import { ClusterCloud } from "./explore/ClusterCloud"; import { ClusterCloud } from './explore/ClusterCloud'
import { ExploreLoadingPanel } from "./explore/ExploreLoadingPanel"; import { ExploreLoadingPanel } from './explore/ExploreLoadingPanel'
import { TagAtlas, TAG_ATLAS_MAX_VISIBLE } from "./explore/TagAtlas"; import { TagAtlas, TAG_ATLAS_MAX_VISIBLE } from './explore/TagAtlas'
import { TagManageList } from "./explore/TagManageList"; import { TagManageList } from './explore/TagManageList'
export function ExploreView() { export function ExploreView() {
const exploreMode = useGalleryStore((state) => state.exploreMode); const exploreMode = useGalleryStore((state) => state.exploreMode)
const setExploreMode = useGalleryStore((state) => state.setExploreMode); const setExploreMode = useGalleryStore((state) => state.setExploreMode)
const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries); const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries)
const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading); const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading)
const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters); const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters)
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries); const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries)
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading); const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading)
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); const loadExploreTags = useGalleryStore((state) => state.loadExploreTags)
const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags); const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags)
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); const showVisualCluster = useGalleryStore((state) => state.showVisualCluster)
const searchForTag = useGalleryStore((state) => state.searchForTag); const searchForTag = useGalleryStore((state) => state.searchForTag)
const renameTag = useGalleryStore((state) => state.renameTag); const renameTag = useGalleryStore((state) => state.renameTag)
const deleteTag = useGalleryStore((state) => state.deleteTag); const deleteTag = useGalleryStore((state) => state.deleteTag)
const resetAiTags = useGalleryStore((state) => state.resetAiTags); const resetAiTags = useGalleryStore((state) => state.resetAiTags)
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
// Manage mode lives in the store so it can be opened from elsewhere (Settings). // Manage mode lives in the store so it can be opened from elsewhere (Settings).
const manageTags = useGalleryStore((state) => state.tagManagerOpen); const manageTags = useGalleryStore((state) => state.tagManagerOpen)
const setManageTags = useGalleryStore((state) => state.setTagManagerOpen); const setManageTags = useGalleryStore((state) => state.setTagManagerOpen)
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); }; const handleDeleteTag = async (tag: string) => {
await deleteTag(tag)
}
const tagManagerScopeLabel = const tagManagerScopeLabel =
selectedFolderId === null selectedFolderId === null
? "all media" ? 'all media'
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "the current folder"; : (folders.find((folder) => folder.id === selectedFolderId)?.name ?? 'the current folder')
const handleResetAiTags = async () => { const handleResetAiTags = async () => {
const count = await resetAiTags(selectedFolderId); const count = await resetAiTags(selectedFolderId)
await loadExploreTags({ force: true }); await loadExploreTags({ force: true })
return count; return count
}; }
useEffect(() => { useEffect(() => {
if (exploreMode === "visual") void loadVisualClusters(); if (exploreMode === 'visual') void loadVisualClusters()
else void loadExploreTags(); else void loadExploreTags()
}, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags]); }, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags])
const loading = exploreMode === "visual" ? visualClusterLoading : exploreTagLoading; const loading = exploreMode === 'visual' ? visualClusterLoading : exploreTagLoading
const hasEntries = exploreMode === "visual" ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0; const hasEntries =
const entryCount = exploreMode === "visual" ? visualClusterEntries.length : exploreTagEntries.length; exploreMode === 'visual' ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE); const entryCount =
exploreMode === 'visual' ? visualClusterEntries.length : exploreTagEntries.length
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE)
return ( return (
<div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]"> <div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
@@ -57,48 +61,54 @@ export function ExploreView() {
<h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2> <h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
<p className="explore-subtitle mt-0.5 truncate text-[11px] text-white/30"> <p className="explore-subtitle mt-0.5 truncate text-[11px] text-white/30">
{loading {loading
? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…" ? exploreMode === 'visual'
? 'Computing visual clusters…'
: 'Loading tags…'
: hasEntries : hasEntries
? exploreMode === "visual" ? exploreMode === 'visual'
? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` ? `${entryCount} cluster${entryCount !== 1 ? 's' : ''} — click any to open`
: manageTags : manageTags
? `${entryCount} tag${entryCount !== 1 ? "s" : ""} available to manage` ? `${entryCount} tag${entryCount !== 1 ? 's' : ''} available to manage`
: visibleTagCount < entryCount : visibleTagCount < entryCount
? `${visibleTagCount} of ${entryCount} tags shown — click any to search` ? `${visibleTagCount} of ${entryCount} tags shown — click any to search`
: `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` : `${entryCount} tag${entryCount !== 1 ? 's' : ''} — click any to search`
: exploreMode === "visual" : exploreMode === 'visual'
? "No clusters — images need embeddings first" ? 'No clusters — images need embeddings first'
: "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 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{exploreMode === "tags" && hasEntries ? ( {exploreMode === 'tags' && hasEntries ? (
<button <button
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${ className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
manageTags manageTags
? "border-white/15 bg-white/10 text-white" ? 'border-white/15 bg-white/10 text-white'
: "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300" : 'border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300'
}`} }`}
onClick={() => setManageTags(!manageTags)} onClick={() => setManageTags(!manageTags)}
> >
{manageTags ? "Done" : "Manage"} {manageTags ? 'Done' : 'Manage'}
</button> </button>
) : null} ) : null}
<FolderScopeDropdown /> <FolderScopeDropdown />
<div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5"> <div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button <button
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${ className={`explore-mode-button 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" exploreMode === 'visual'
? 'bg-white/10 text-white'
: 'text-gray-500 hover:text-gray-300'
}`} }`}
onClick={() => setExploreMode("visual")} onClick={() => setExploreMode('visual')}
> >
Clusters Clusters
</button> </button>
<button <button
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${ className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300" exploreMode === 'tags'
? 'bg-white/10 text-white'
: 'text-gray-500 hover:text-gray-300'
}`} }`}
onClick={() => setExploreMode("tags")} onClick={() => setExploreMode('tags')}
> >
Tag Cloud Tag Cloud
</button> </button>
@@ -112,12 +122,12 @@ export function ExploreView() {
) : !hasEntries ? ( ) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8"> <div className="flex flex-1 items-center justify-center px-8">
<p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25"> <p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
{exploreMode === "visual" {exploreMode === 'visual'
? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar." ? 'No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar.'
: "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."} : 'No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview.'}
</p> </p>
</div> </div>
) : exploreMode === "visual" ? ( ) : exploreMode === 'visual' ? (
<div className="relative flex min-h-0 flex-1 flex-col"> <div className="relative flex min-h-0 flex-1 flex-col">
<ClusterCloud entries={visualClusterEntries} onOpen={showVisualCluster} /> <ClusterCloud entries={visualClusterEntries} onOpen={showVisualCluster} />
</div> </div>
@@ -133,8 +143,12 @@ export function ExploreView() {
/> />
</div> </div>
) : ( ) : (
<TagAtlas entries={exploreTagEntries} onSearch={searchForTag} loadRelatedTags={loadRelatedTags} /> <TagAtlas
entries={exploreTagEntries}
onSearch={searchForTag}
loadRelatedTags={loadRelatedTags}
/>
)} )}
</div> </div>
); )
} }
+58 -44
View File
@@ -1,23 +1,23 @@
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from '@tanstack/react-virtual'
import { Tooltip } from "./Tooltip"; import { Tooltip } from './Tooltip'
import { CloseIcon } from "./icons"; import { CloseIcon } from './icons'
import { FolderRow } from "./folderPicker/FolderRow"; import { FolderRow } from './folderPicker/FolderRow'
import { StagedFoldersPanel } from "./folderPicker/StagedFoldersPanel"; import { StagedFoldersPanel } from './folderPicker/StagedFoldersPanel'
import { StatusLine } from "./folderPicker/StatusLine"; import { StatusLine } from './folderPicker/StatusLine'
import { normalizePath } from "./folderPicker/pathUtils"; import { normalizePath } from './folderPicker/pathUtils'
import { useFolderPicker } from "./folderPicker/useFolderPicker"; import { useFolderPicker } from './folderPicker/useFolderPicker'
export function FolderPickerModal() { export function FolderPickerModal() {
const folderPicker = useFolderPicker(); const folderPicker = useFolderPicker()
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: folderPicker.entries.length, count: folderPicker.entries.length,
getScrollElement: () => folderPicker.scrollRef.current, getScrollElement: () => folderPicker.scrollRef.current,
estimateSize: () => 48, estimateSize: () => 48,
overscan: 8, overscan: 8,
}); })
if (!folderPicker.folderPickerOpen) return null; if (!folderPicker.folderPickerOpen) return null
return ( return (
<div <div
@@ -25,19 +25,21 @@ export function FolderPickerModal() {
onClick={() => folderPicker.setFolderPickerOpen(false)} onClick={() => folderPicker.setFolderPickerOpen(false)}
> >
<div <div
className="relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70" className="light-theme:border-gray-300/70 relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<header className="border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-200"> <header className="light-theme:border-gray-200 border-b border-white/[0.07] px-5 py-4">
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-base font-semibold text-white">Add media folders</p> <p className="text-base font-semibold text-white">Add media folders</p>
<p className="mt-1 text-xs text-gray-500 light-theme:text-gray-600">Choose folders from any location, then add them together.</p> <p className="light-theme:text-gray-600 mt-1 text-xs text-gray-500">
Choose folders from any location, then add them together.
</p>
</div> </div>
<Tooltip label="Close folder picker" anchorToCursor> <Tooltip label="Close folder picker" anchorToCursor>
<button <button
type="button" type="button"
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white" className="light-theme:hover:bg-gray-900 light-theme:hover:text-white rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={() => folderPicker.setFolderPickerOpen(false)} onClick={() => folderPicker.setFolderPickerOpen(false)}
> >
<CloseIcon className="h-4 w-4" /> <CloseIcon className="h-4 w-4" />
@@ -51,7 +53,7 @@ export function FolderPickerModal() {
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<button <button
type="button" type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => folderPicker.setCurrentPath(folderPicker.listing?.parent ?? null)} onClick={() => folderPicker.setCurrentPath(folderPicker.listing?.parent ?? null)}
disabled={!folderPicker.listing?.current} disabled={!folderPicker.listing?.current}
> >
@@ -61,15 +63,17 @@ export function FolderPickerModal() {
<form <form
className="flex min-w-0 flex-1 items-center gap-2" className="flex min-w-0 flex-1 items-center gap-2"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault()
folderPicker.navigateToAddress(); folderPicker.navigateToAddress()
}} }}
> >
<label className="sr-only" htmlFor="folder-picker-address">Folder path</label> <label className="sr-only" htmlFor="folder-picker-address">
Folder path
</label>
<input <input
ref={folderPicker.addressInputRef} ref={folderPicker.addressInputRef}
id="folder-picker-address" id="folder-picker-address"
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 font-mono text-xs text-gray-200 placeholder-gray-600 outline-none transition-colors focus:border-white/25 focus:bg-white/[0.055] light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:placeholder-gray-500 light-theme:focus:bg-gray-800" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:placeholder-gray-500 light-theme:focus:bg-gray-800 min-w-0 flex-1 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 font-mono text-xs text-gray-200 placeholder-gray-600 transition-colors outline-none focus:border-white/25 focus:bg-white/[0.055]"
value={folderPicker.addressDraft} value={folderPicker.addressDraft}
onChange={(event) => folderPicker.updateAddressDraft(event.target.value)} onChange={(event) => folderPicker.updateAddressDraft(event.target.value)}
placeholder="Paste or type a folder path" placeholder="Paste or type a folder path"
@@ -77,27 +81,30 @@ export function FolderPickerModal() {
/> />
<button <button
type="submit" type="submit"
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={folderPicker.loading} disabled={folderPicker.loading}
> >
Go Go
</button> </button>
</form> </form>
) : ( ) : (
<div <div className="light-theme:border-gray-300/70 light-theme:bg-gray-900 flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5">
className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5 light-theme:border-gray-300/70 light-theme:bg-gray-900"
>
<nav className="flex min-w-0 items-center gap-1 overflow-hidden"> <nav className="flex min-w-0 items-center gap-1 overflow-hidden">
{folderPicker.breadcrumbs.map((crumb, index) => ( {folderPicker.breadcrumbs.map((crumb, index) => (
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1"> <span
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null} key={`${crumb.path ?? 'root'}-${index}`}
<Tooltip label={crumb.path ?? "Roots"} anchorToCursor> className="flex min-w-0 items-center gap-1"
>
{index > 0 ? (
<span className="light-theme:text-gray-400 text-gray-700">/</span>
) : null}
<Tooltip label={crumb.path ?? 'Roots'} anchorToCursor>
<button <button
type="button" type="button"
className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
folderPicker.setCurrentPath(crumb.path); folderPicker.setCurrentPath(crumb.path)
}} }}
> >
{crumb.label} {crumb.label}
@@ -108,7 +115,7 @@ export function FolderPickerModal() {
</nav> </nav>
<button <button
type="button" type="button"
className="min-w-10 flex-1 self-stretch cursor-text rounded px-1" className="min-w-10 flex-1 cursor-text self-stretch rounded px-1"
onClick={folderPicker.beginAddressEdit} onClick={folderPicker.beginAddressEdit}
aria-label="Edit folder path" aria-label="Edit folder path"
/> />
@@ -129,28 +136,35 @@ export function FolderPickerModal() {
</div> </div>
{folderPicker.error ? ( {folderPicker.error ? (
<div className="mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200 light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800"> <div className="light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800 mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
{folderPicker.error} {folderPicker.error}
</div> </div>
) : null} ) : null}
<div ref={folderPicker.scrollRef} className="min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2 light-theme:border-gray-300/70 light-theme:bg-gray-900/50"> <div
ref={folderPicker.scrollRef}
className="light-theme:border-gray-300/70 light-theme:bg-gray-900/50 min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2"
>
{folderPicker.loading ? ( {folderPicker.loading ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">Loading folders...</div> <div className="flex h-full items-center justify-center text-sm text-gray-500">
Loading folders...
</div>
) : folderPicker.entries.length === 0 ? ( ) : folderPicker.entries.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">No folders found here.</div> <div className="flex h-full items-center justify-center text-sm text-gray-500">
No folders found here.
</div>
) : ( ) : (
<div <div
className="relative w-full" className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }} style={{ height: `${virtualizer.getTotalSize()}px` }}
> >
{virtualizer.getVirtualItems().map((virtualItem) => { {virtualizer.getVirtualItems().map((virtualItem) => {
const entry = folderPicker.entries[virtualItem.index]; const entry = folderPicker.entries[virtualItem.index]
const normalized = normalizePath(entry.path); const normalized = normalizePath(entry.path)
return ( return (
<div <div
key={virtualItem.key} key={virtualItem.key}
className="absolute left-0 top-0 w-full px-0.5" className="absolute top-0 left-0 w-full px-0.5"
style={{ style={{
height: `${virtualItem.size}px`, height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`, transform: `translateY(${virtualItem.start}px)`,
@@ -164,7 +178,7 @@ export function FolderPickerModal() {
onNavigate={() => folderPicker.setCurrentPath(entry.path)} onNavigate={() => folderPicker.setCurrentPath(entry.path)}
/> />
</div> </div>
); )
})} })}
</div> </div>
)} )}
@@ -178,7 +192,7 @@ export function FolderPickerModal() {
/> />
</div> </div>
<footer className="border-t border-white/[0.07] px-5 py-4 light-theme:border-gray-200"> <footer className="light-theme:border-gray-200 border-t border-white/[0.07] px-5 py-4">
<div className="flex items-end justify-between gap-4"> <div className="flex items-end justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<StatusLine results={folderPicker.results} /> <StatusLine results={folderPicker.results} />
@@ -187,23 +201,23 @@ export function FolderPickerModal() {
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<button <button
type="button" type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white"
onClick={() => folderPicker.setFolderPickerOpen(false)} onClick={() => folderPicker.setFolderPickerOpen(false)}
> >
Cancel Cancel
</button> </button>
<button <button
type="button" type="button"
className="rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void folderPicker.confirmAdd()} onClick={() => void folderPicker.confirmAdd()}
disabled={folderPicker.stagedPaths.length === 0 || folderPicker.adding} disabled={folderPicker.stagedPaths.length === 0 || folderPicker.adding}
> >
{folderPicker.adding ? "Adding..." : `Add ${folderPicker.stagedPaths.length}`} {folderPicker.adding ? 'Adding...' : `Add ${folderPicker.stagedPaths.length}`}
</button> </button>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
</div> </div>
); )
} }
+22 -12
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from 'react'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { Dropdown, DropdownOption } from "./menu"; import { Dropdown, DropdownOption } from './menu'
/** /**
* In-view folder scope picker for feature views (Timeline / Explore / * In-view folder scope picker for feature views (Timeline / Explore /
@@ -8,21 +8,21 @@ import { Dropdown, DropdownOption } from "./menu";
* current view active — unlike sidebar folder clicks, which jump to Gallery. * current view active — unlike sidebar folder clicks, which jump to Gallery.
*/ */
export function FolderScopeDropdown() { export function FolderScopeDropdown() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope); const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope)
const options = useMemo<DropdownOption<number | null>[]>( const options = useMemo<DropdownOption<number | null>[]>(
() => [ () => [
{ value: null, label: "All Media" }, { value: null, label: 'All Media' },
...folders.map((folder) => ({ ...folders.map((folder) => ({
value: folder.id, value: folder.id,
label: folder.name, label: folder.name,
hint: <span className="tabular-nums">{folder.image_count.toLocaleString()}</span>, hint: <span className="tabular-nums">{folder.image_count.toLocaleString()}</span>,
})), })),
], ],
[folders], [folders]
); )
return ( return (
<Dropdown <Dropdown
@@ -36,10 +36,20 @@ export function FolderScopeDropdown() {
triggerClassName="max-w-56" triggerClassName="max-w-56"
panelClassName="min-w-52 max-h-80 overflow-y-auto" panelClassName="min-w-52 max-h-80 overflow-y-auto"
triggerIcon={ triggerIcon={
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<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" /> 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> </svg>
} }
/> />
); )
} }
+71 -64
View File
@@ -1,50 +1,54 @@
import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from 'react'
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from '@tanstack/react-virtual'
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from '../store'
import { BulkActionBar } from "./BulkActionBar"; import { BulkActionBar } from './BulkActionBar'
import { ImageContextMenu } from "./ImageContextMenu"; import { ImageContextMenu } from './ImageContextMenu'
import { GalleryEmptyState, GalleryLoadingState } from "./gallery/GalleryEmptyState"; import { GalleryEmptyState, GalleryLoadingState } from './gallery/GalleryEmptyState'
import { ImageTile } from "./gallery/ImageTile"; import { ImageTile } from './gallery/ImageTile'
const GAP = 6; const GAP = 6
export function Gallery() { export function Gallery() {
const images = useGalleryStore((state) => state.images); const images = useGalleryStore((state) => state.images)
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages); const loadMoreImages = useGalleryStore((state) => state.loadMoreImages)
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage)
const totalImages = useGalleryStore((state) => state.totalImages); const totalImages = useGalleryStore((state) => state.totalImages)
const loadingImages = useGalleryStore((state) => state.loadingImages); const loadingImages = useGalleryStore((state) => state.loadingImages)
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset)
const search = useGalleryStore((state) => state.search); const search = useGalleryStore((state) => state.search)
const collectionTitle = useGalleryStore((state) => state.collectionTitle); const collectionTitle = useGalleryStore((state) => state.collectionTitle)
const imageLoadError = useGalleryStore((state) => state.imageLoadError); const imageLoadError = useGalleryStore((state) => state.imageLoadError)
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey); const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey)
const isSimilarResults = collectionTitle === "Similar Images"; const isSimilarResults = collectionTitle === 'Similar Images'
const parsedSearch = parseSearchValue(search); const parsedSearch = parseSearchValue(search)
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); const [contextMenu, setContextMenu] = useState<{
x: number
y: number
image: ImageRecord
} | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
const el = parentRef.current; const el = parentRef.current
if (!el) return; if (!el) return
setContainerWidth(el.clientWidth); setContainerWidth(el.clientWidth)
const ro = new ResizeObserver((entries) => { const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width); setContainerWidth(entries[0].contentRect.width)
}); })
ro.observe(el); ro.observe(el)
return () => ro.disconnect(); return () => ro.disconnect()
}, []); }, [])
const tileSize = tileSizeForZoom(zoomPreset); const tileSize = tileSizeForZoom(zoomPreset)
const cols = useMemo( const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))), () => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize], [containerWidth, tileSize]
); )
const rowCount = Math.ceil(images.length / cols); const rowCount = Math.ceil(images.length / cols)
const estimateSize = useCallback(() => tileSize + GAP, [tileSize]); const estimateSize = useCallback(() => tileSize + GAP, [tileSize])
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rowCount, count: rowCount,
@@ -52,36 +56,39 @@ export function Gallery() {
estimateSize, estimateSize,
overscan: 3, overscan: 3,
paddingStart: GAP, paddingStart: GAP,
}); })
useEffect(() => { useEffect(() => {
virtualizer.measure(); virtualizer.measure()
}, [cols, virtualizer]); }, [cols, virtualizer])
useEffect(() => { useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 }); parentRef.current?.scrollTo({ top: 0, left: 0 })
}, [galleryScrollResetKey]); }, [galleryScrollResetKey])
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = parentRef.current; const el = parentRef.current
if (!el) return; if (!el) return
if (el.scrollTop < 24) return; if (el.scrollTop < 24) return
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600
if (nearBottom && !loadingImages && images.length < totalImages) { if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages(); void loadMoreImages()
} }
}, [images.length, loadMoreImages, loadingImages, totalImages]); }, [images.length, loadMoreImages, loadingImages, totalImages])
useEffect(() => { useEffect(() => {
const el = parentRef.current; const el = parentRef.current
if (!el) return; if (!el) return
el.addEventListener("scroll", handleScroll, { passive: true }); el.addEventListener('scroll', handleScroll, { passive: true })
return () => el.removeEventListener("scroll", handleScroll); return () => el.removeEventListener('scroll', handleScroll)
}, [handleScroll]); }, [handleScroll])
return ( return (
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950"> <div
ref={parentRef}
className="absolute inset-0 overflow-x-hidden overflow-y-auto bg-gray-950"
>
{images.length === 0 && loadingImages ? ( {images.length === 0 && loadingImages ? (
<GalleryLoadingState isSimilarResults={isSimilarResults} parsedSearch={parsedSearch} /> <GalleryLoadingState isSimilarResults={isSimilarResults} parsedSearch={parsedSearch} />
) : images.length === 0 && !loadingImages ? ( ) : images.length === 0 && !loadingImages ? (
@@ -91,25 +98,25 @@ export function Gallery() {
parsedSearch={parsedSearch} parsedSearch={parsedSearch}
/> />
) : ( ) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}> <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => { {virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * cols; const startIndex = virtualRow.index * cols
const rowImages = images.slice(startIndex, startIndex + cols); const rowImages = images.slice(startIndex, startIndex + cols)
return ( return (
<div <div
key={virtualRow.key} key={virtualRow.key}
style={{ style={{
position: "absolute", position: 'absolute',
top: virtualRow.start, top: virtualRow.start,
width: "100%", width: '100%',
height: virtualRow.size, height: virtualRow.size,
display: "grid", display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`, gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP, gap: GAP,
paddingLeft: GAP, paddingLeft: GAP,
paddingRight: GAP, paddingRight: GAP,
paddingBottom: GAP, paddingBottom: GAP,
boxSizing: "border-box", boxSizing: 'border-box',
}} }}
> >
{rowImages.map((image) => ( {rowImages.map((image) => (
@@ -118,13 +125,13 @@ export function Gallery() {
image={image} image={image}
onClick={() => openImage(image)} onClick={() => openImage(image)}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault(); event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY, image }); setContextMenu({ x: event.clientX, y: event.clientY, image })
}} }}
/> />
))} ))}
</div> </div>
); )
})} })}
</div> </div>
)} )}
@@ -149,5 +156,5 @@ export function Gallery() {
container so it stays put while the grid scrolls. */} container so it stays put while the grid scrolls. */}
<BulkActionBar /> <BulkActionBar />
</div> </div>
); )
} }
+31 -23
View File
@@ -1,7 +1,7 @@
import { ImageRecord, useGalleryStore } from "../store"; import { ImageRecord, useGalleryStore } from '../store'
import { ContextMenu, MenuItem, MenuLabel, MenuSeparator, SubMenu } from "./menu"; import { ContextMenu, MenuItem, MenuLabel, MenuSeparator, SubMenu } from './menu'
import { Tooltip } from "./Tooltip"; import { Tooltip } from './Tooltip'
import { CloseIcon, StarIcon } from "./icons"; import { CloseIcon, StarIcon } from './icons'
/** Right-click menu for an image tile. Shared by the Gallery grid and the Timeline. */ /** Right-click menu for an image tile. Shared by the Gallery grid and the Timeline. */
export function ImageContextMenu({ export function ImageContextMenu({
@@ -10,27 +10,27 @@ export function ImageContextMenu({
image, image,
onClose, onClose,
}: { }: {
x: number; x: number
y: number; y: number
image: ImageRecord; image: ImageRecord
onClose: () => void; onClose: () => void
}) { }) {
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage)
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails)
const findSimilar = useGalleryStore((state) => state.findSimilar); const findSimilar = useGalleryStore((state) => state.findSimilar)
const albums = useGalleryStore((state) => state.albums); const albums = useGalleryStore((state) => state.albums)
const addToAlbum = useGalleryStore((state) => state.addToAlbum); const addToAlbum = useGalleryStore((state) => state.addToAlbum)
const canFindSimilar = image.embedding_status === "ready"; const canFindSimilar = image.embedding_status === 'ready'
return ( return (
<ContextMenu x={x} y={y} onClose={onClose}> <ContextMenu x={x} y={y} onClose={onClose}>
<MenuItem label="Open Preview" onSelect={() => openImage(image)} /> <MenuItem label="Open Preview" onSelect={() => openImage(image)} />
<MenuItem <MenuItem
label={image.favorite ? "Remove Favorite" : "Add to Favorites"} label={image.favorite ? 'Remove Favorite' : 'Add to Favorites'}
onSelect={() => void updateImageDetails(image.id, { favorite: !image.favorite })} onSelect={() => void updateImageDetails(image.id, { favorite: !image.favorite })}
/> />
<MenuItem <MenuItem
label={canFindSimilar ? "Find Similar" : "Embeddings not ready"} label={canFindSimilar ? 'Find Similar' : 'Embeddings not ready'}
disabled={!canFindSimilar} disabled={!canFindSimilar}
onSelect={() => findSimilar(image.id, image.folder_id)} onSelect={() => findSimilar(image.id, image.folder_id)}
/> />
@@ -52,23 +52,31 @@ export function ImageContextMenu({
<MenuLabel>Rating</MenuLabel> <MenuLabel>Rating</MenuLabel>
<div className="flex items-center gap-0.5 px-2 pb-1.5"> <div className="flex items-center gap-0.5 px-2 pb-1.5">
{Array.from({ length: 5 }, (_, index) => { {Array.from({ length: 5 }, (_, index) => {
const rating = index + 1; const rating = index + 1
return ( return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor> <Tooltip key={rating} label={`Set ${rating} star rating`} followCursor>
<button <button
className="rounded-md p-1 transition-colors hover:bg-white/5" className="rounded-md p-1 transition-colors hover:bg-white/5"
onClick={async () => { await updateImageDetails(image.id, { rating }); onClose(); }} onClick={async () => {
await updateImageDetails(image.id, { rating })
onClose()
}}
> >
<StarIcon className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`} /> <StarIcon
className={`h-4 w-4 ${rating <= image.rating ? 'text-amber-300' : 'text-white/20 hover:text-white/40'}`}
/>
</button> </button>
</Tooltip> </Tooltip>
); )
})} })}
{image.rating > 0 ? ( {image.rating > 0 ? (
<Tooltip label="Remove rating" followCursor> <Tooltip label="Remove rating" followCursor>
<button <button
className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors" className="ml-1 rounded-md p-1 text-gray-600 transition-colors hover:bg-white/5 hover:text-gray-300"
onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }} onClick={async () => {
await updateImageDetails(image.id, { rating: 0 })
onClose()
}}
> >
<CloseIcon className="h-3 w-3" /> <CloseIcon className="h-3 w-3" />
</button> </button>
@@ -76,5 +84,5 @@ export function ImageContextMenu({
) : null} ) : null}
</div> </div>
</ContextMenu> </ContextMenu>
); )
} }
+6 -6
View File
@@ -6,23 +6,23 @@ export function InlineConfirm({
onConfirm, onConfirm,
onCancel, onCancel,
}: { }: {
onConfirm: () => void; onConfirm: () => void
onCancel: () => void; onCancel: () => void
}) { }) {
return ( return (
<div className="flex items-center gap-1 shrink-0" onClick={(event) => event.stopPropagation()}> <div className="flex shrink-0 items-center gap-1" onClick={(event) => event.stopPropagation()}>
<button <button
className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300 transition-colors" className="rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/30 hover:text-red-300"
onClick={onConfirm} onClick={onConfirm}
> >
Confirm Confirm
</button> </button>
<button <button
className="px-1.5 py-0.5 text-[10px] rounded bg-white/5 text-gray-500 hover:bg-white/10 hover:text-gray-300 transition-colors" className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-gray-500 transition-colors hover:bg-white/10 hover:text-gray-300"
onClick={onCancel} onClick={onCancel}
> >
Cancel Cancel
</button> </button>
</div> </div>
); )
} }
+19 -19
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react'
/** /**
* In-place rename input for sidebar rows (folders, albums). Mount it in * In-place rename input for sidebar rows (folders, albums). Mount it in
@@ -10,41 +10,41 @@ export function InlineRename({
onRename, onRename,
onClose, onClose,
}: { }: {
name: string; name: string
onRename: (next: string) => Promise<void> | void; onRename: (next: string) => Promise<void> | void
onClose: () => void; onClose: () => void
}) { }) {
const [value, setValue] = useState(name); const [value, setValue] = useState(name)
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus()
inputRef.current?.select(); inputRef.current?.select()
}, []); }, [])
const commit = async () => { const commit = async () => {
const trimmed = value.trim(); const trimmed = value.trim()
if (trimmed && trimmed !== name) { if (trimmed && trimmed !== name) {
await onRename(trimmed); await onRename(trimmed)
} }
onClose(); onClose()
}; }
return ( return (
<input <input
ref={inputRef} ref={inputRef}
className="w-full bg-white/10 text-white text-[13px] font-medium rounded px-1 py-0 outline-none ring-1 ring-blue-500/60 leading-tight" className="w-full rounded bg-white/10 px-1 py-0 text-[13px] leading-tight font-medium text-white ring-1 ring-blue-500/60 outline-none"
value={value} value={value}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault()
void commit(); void commit()
} }
if (event.key === "Escape") onClose(); if (event.key === 'Escape') onClose()
}} }}
onBlur={() => void commit()} onBlur={() => void commit()}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
/> />
); )
} }
+70 -62
View File
@@ -1,57 +1,59 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from 'react'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { LightboxDetailsPanel } from "./lightbox/LightboxDetailsPanel"; import { LightboxDetailsPanel } from './lightbox/LightboxDetailsPanel'
import { LightboxNavButton } from "./lightbox/LightboxNavButton"; import { LightboxNavButton } from './lightbox/LightboxNavButton'
import { LightboxViewport } from "./lightbox/LightboxViewport"; import { LightboxViewport } from './lightbox/LightboxViewport'
import { SlideshowView } from "./lightbox/SlideshowView"; import { SlideshowView } from './lightbox/SlideshowView'
import { useLightboxMediaDetails } from "./lightbox/useLightboxMediaDetails"; import { useLightboxMediaDetails } from './lightbox/useLightboxMediaDetails'
import { useLightboxNavigation } from "./lightbox/useLightboxNavigation"; import { useLightboxNavigation } from './lightbox/useLightboxNavigation'
import { useRegionSelection } from "./lightbox/useRegionSelection"; import { useRegionSelection } from './lightbox/useRegionSelection'
import { useSlideshow } from "./lightbox/useSlideshow"; import { useSlideshow } from './lightbox/useSlideshow'
import { ViewTransform } from "./lightbox/types"; import { ViewTransform } from './lightbox/types'
import { IDENTITY_VIEW } from "./lightbox/viewTransform"; import { IDENTITY_VIEW } from './lightbox/viewTransform'
export function Lightbox() { export function Lightbox() {
const selectedImage = useGalleryStore((state) => state.selectedImage); const selectedImage = useGalleryStore((state) => state.selectedImage)
const closeImage = useGalleryStore((state) => state.closeImage); const closeImage = useGalleryStore((state) => state.closeImage)
const images = useGalleryStore((state) => state.images); const images = useGalleryStore((state) => state.images)
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage)
const findSimilar = useGalleryStore((state) => state.findSimilar); const findSimilar = useGalleryStore((state) => state.findSimilar)
const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion); const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion)
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails)
const getImageTags = useGalleryStore((state) => state.getImageTags); const getImageTags = useGalleryStore((state) => state.getImageTags)
const addUserTag = useGalleryStore((state) => state.addUserTag); const addUserTag = useGalleryStore((state) => state.addUserTag)
const removeTag = useGalleryStore((state) => state.removeTag); const removeTag = useGalleryStore((state) => state.removeTag)
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus)
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus); const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus)
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage); const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage)
const albums = useGalleryStore((state) => state.albums); const albums = useGalleryStore((state) => state.albums)
const addToAlbum = useGalleryStore((state) => state.addToAlbum); const addToAlbum = useGalleryStore((state) => state.addToAlbum)
const createAlbum = useGalleryStore((state) => state.createAlbum); const createAlbum = useGalleryStore((state) => state.createAlbum)
const getImageExif = useGalleryStore((state) => state.getImageExif); const getImageExif = useGalleryStore((state) => state.getImageExif)
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages); const loadMoreImages = useGalleryStore((state) => state.loadMoreImages)
const loadedCount = useGalleryStore((state) => state.loadedCount); const loadedCount = useGalleryStore((state) => state.loadedCount)
const totalImages = useGalleryStore((state) => state.totalImages); const totalImages = useGalleryStore((state) => state.totalImages)
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds); const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds)
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder); const slideshowOrder = useGalleryStore((state) => state.slideshowOrder)
const slideshowTransition = useGalleryStore((state) => state.slideshowTransition); const slideshowTransition = useGalleryStore((state) => state.slideshowTransition)
const lightboxRootRef = useRef<HTMLDivElement>(null); const lightboxRootRef = useRef<HTMLDivElement>(null)
const imageViewportRef = useRef<HTMLDivElement>(null); const imageViewportRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null)
const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW); const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW)
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1; const currentIndex = selectedImage
const canFindSimilar = selectedImage?.embedding_status === "ready"; ? images.findIndex((image) => image.id === selectedImage.id)
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image"; : -1
const taggerReady = taggerModelStatus?.ready ?? false; const canFindSimilar = selectedImage?.embedding_status === 'ready'
const taggerStatusKnown = taggerModelStatus !== null; const canSearchRegion = canFindSimilar && selectedImage?.media_kind === 'image'
const taggerReady = taggerModelStatus?.ready ?? false
const taggerStatusKnown = taggerModelStatus !== null
const taggerButtonTooltip = !taggerStatusKnown const taggerButtonTooltip = !taggerStatusKnown
? "Checking AI tagger model..." ? 'Checking AI tagger model...'
: taggerReady : taggerReady
? "Queue AI tagging for this image" ? 'Queue AI tagging for this image'
: "AI tagger model not installed"; : 'AI tagger model not installed'
const region = useRegionSelection({ const region = useRegionSelection({
selectedImage, selectedImage,
@@ -61,7 +63,7 @@ export function Lightbox() {
view, view,
setView, setView,
findSimilarByRegion, findSimilarByRegion,
}); })
const slideshow = useSlideshow({ const slideshow = useSlideshow({
rootRef: lightboxRootRef, rootRef: lightboxRootRef,
@@ -77,13 +79,13 @@ export function Lightbox() {
loadMoreImages, loadMoreImages,
exitRegionMode: region.exitRegionMode, exitRegionMode: region.exitRegionMode,
setView, setView,
}); })
const resetForSelectedImage = useCallback(() => { const resetForSelectedImage = useCallback(() => {
setView(IDENTITY_VIEW); setView(IDENTITY_VIEW)
region.exitRegionMode(); region.exitRegionMode()
region.setRegionSearching(false); region.setRegionSearching(false)
}, [region.exitRegionMode, region.setRegionSearching]); }, [region.exitRegionMode, region.setRegionSearching])
const details = useLightboxMediaDetails({ const details = useLightboxMediaDetails({
selectedImage, selectedImage,
@@ -92,7 +94,7 @@ export function Lightbox() {
getImageExif, getImageExif,
loadTaggerModelStatus, loadTaggerModelStatus,
onSelectedImageReset: resetForSelectedImage, onSelectedImageReset: resetForSelectedImage,
}); })
const { goPrev, goNext } = useLightboxNavigation({ const { goPrev, goNext } = useLightboxNavigation({
selectedImage, selectedImage,
@@ -109,15 +111,15 @@ export function Lightbox() {
openImage, openImage,
setView, setView,
clampPan: region.clampPan, clampPan: region.clampPan,
}); })
const toggleRegionMode = useCallback(() => { const toggleRegionMode = useCallback(() => {
if (region.regionSelectMode) { if (region.regionSelectMode) {
region.exitRegionMode(); region.exitRegionMode()
} else { } else {
region.setRegionSelectMode(true); region.setRegionSelectMode(true)
} }
}, [region.exitRegionMode, region.regionSelectMode, region.setRegionSelectMode]); }, [region.exitRegionMode, region.regionSelectMode, region.setRegionSelectMode])
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -126,13 +128,19 @@ export function Lightbox() {
ref={lightboxRootRef} ref={lightboxRootRef}
key="lightbox" key="lightbox"
className={`media-dark-surface fixed inset-0 z-50 flex ${ className={`media-dark-surface fixed inset-0 z-50 flex ${
slideshow.active ? "bg-black" : "bg-black/90 backdrop-blur-sm" slideshow.active ? 'bg-black' : 'bg-black/90 backdrop-blur-sm'
}`} }`}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
onClick={slideshow.active ? slideshow.showControls : region.regionSelectMode ? undefined : closeImage} onClick={
slideshow.active
? slideshow.showControls
: region.regionSelectMode
? undefined
: closeImage
}
> >
{slideshow.active ? ( {slideshow.active ? (
<SlideshowView <SlideshowView
@@ -223,5 +231,5 @@ export function Lightbox() {
</motion.div> </motion.div>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
); )
} }
+7 -5
View File
@@ -6,14 +6,14 @@
// //
// Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point — // Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point —
// used in the titlebar as the "update available" indicator. // used in the titlebar as the "update available" indicator.
const BLADE = "M0,-4.18 A10,10 0 0 1 6.43,-7.66"; const BLADE = 'M0,-4.18 A10,10 0 0 1 6.43,-7.66'
export function PhokusMark({ export function PhokusMark({
className, className,
dotClassName, dotClassName,
}: { }: {
className?: string; className?: string
dotClassName?: string; dotClassName?: string
}) { }) {
return ( return (
<svg viewBox="0 0 24 24" fill="none" className={className}> <svg viewBox="0 0 24 24" fill="none" className={className}>
@@ -33,7 +33,9 @@ export function PhokusMark({
<path d={BLADE} transform="rotate(240)" /> <path d={BLADE} transform="rotate(240)" />
<path d={BLADE} transform="rotate(300)" /> <path d={BLADE} transform="rotate(300)" />
</g> </g>
{dotClassName ? <circle cx="12" cy="12" r="2.6" stroke="none" className={dotClassName} /> : null} {dotClassName ? (
<circle cx="12" cy="12" r="2.6" stroke="none" className={dotClassName} />
) : null}
</svg> </svg>
); )
} }
+55 -49
View File
@@ -1,58 +1,58 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { Tooltip } from "./Tooltip"; import { Tooltip } from './Tooltip'
import { CloseIcon } from "./icons"; import { CloseIcon } from './icons'
import { AiWorkspaceSettingsSection } from "./settings/AiWorkspaceSettingsSection"; import { AiWorkspaceSettingsSection } from './settings/AiWorkspaceSettingsSection'
import { GeneralSettingsSection } from "./settings/GeneralSettingsSection"; import { GeneralSettingsSection } from './settings/GeneralSettingsSection'
import { MediaSettingsSection } from "./settings/MediaSettingsSection"; import { MediaSettingsSection } from './settings/MediaSettingsSection'
import { StorageSettingsSection } from "./settings/StorageSettingsSection"; import { StorageSettingsSection } from './settings/StorageSettingsSection'
import { UpdatesSettingsSection } from "./settings/UpdatesSettingsSection"; import { UpdatesSettingsSection } from './settings/UpdatesSettingsSection'
import { SETTINGS_SECTIONS, SettingsSection } from "./settings/shared"; import { SETTINGS_SECTIONS, SettingsSection } from './settings/shared'
function ActiveSettingsSection({ section }: { section: SettingsSection }) { function ActiveSettingsSection({ section }: { section: SettingsSection }) {
switch (section) { switch (section) {
case "workspace": case 'workspace':
return <AiWorkspaceSettingsSection />; return <AiWorkspaceSettingsSection />
case "media": case 'media':
return <MediaSettingsSection />; return <MediaSettingsSection />
case "updates": case 'updates':
return <UpdatesSettingsSection />; return <UpdatesSettingsSection />
case "storage": case 'storage':
return <StorageSettingsSection />; return <StorageSettingsSection />
case "general": case 'general':
default: default:
return <GeneralSettingsSection />; return <GeneralSettingsSection />
} }
} }
export function SettingsModal() { export function SettingsModal() {
const [activeSection, setActiveSection] = useState<SettingsSection>("general"); const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const settingsOpen = useGalleryStore((state) => state.settingsOpen); const settingsOpen = useGalleryStore((state) => state.settingsOpen)
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen); const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen)
const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope); const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope)
const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds); const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds)
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus); const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus)
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration); const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration)
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel); const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel)
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold); const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold)
const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize); const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize)
useEffect(() => { useEffect(() => {
if (!settingsOpen) return; if (!settingsOpen) return
void loadTaggerModelStatus(); void loadTaggerModelStatus()
void loadTaggerModel(); void loadTaggerModel()
void loadTaggerAcceleration(); void loadTaggerAcceleration()
void loadTaggerThreshold(); void loadTaggerThreshold()
void loadTaggerBatchSize(); void loadTaggerBatchSize()
void loadTaggingQueueScope(); void loadTaggingQueueScope()
void loadTaggingQueueFolderIds(); void loadTaggingQueueFolderIds()
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsOpen(false); if (event.key === 'Escape') setSettingsOpen(false)
}; }
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown)
}, [ }, [
settingsOpen, settingsOpen,
loadTaggerModelStatus, loadTaggerModelStatus,
@@ -63,14 +63,18 @@ export function SettingsModal() {
loadTaggingQueueScope, loadTaggingQueueScope,
loadTaggingQueueFolderIds, loadTaggingQueueFolderIds,
setSettingsOpen, setSettingsOpen,
]); ])
if (!settingsOpen) return null; if (!settingsOpen) return null
const activeSectionMeta = SETTINGS_SECTIONS.find((section) => section.id === activeSection) ?? SETTINGS_SECTIONS[0]; const activeSectionMeta =
SETTINGS_SECTIONS.find((section) => section.id === activeSection) ?? SETTINGS_SECTIONS[0]
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="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-gray-950 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-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
@@ -84,7 +88,9 @@ export function SettingsModal() {
<button <button
key={section.id} key={section.id}
className={`w-full rounded-md px-3 py-2.5 text-left transition-colors ${ className={`w-full rounded-md px-3 py-2.5 text-left transition-colors ${
activeSection === section.id ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/[0.055] hover:text-gray-200" activeSection === section.id
? 'bg-white/10 text-white'
: 'text-gray-500 hover:bg-white/[0.055] hover:text-gray-200'
}`} }`}
onClick={() => setActiveSection(section.id)} onClick={() => setActiveSection(section.id)}
> >
@@ -95,7 +101,7 @@ export function SettingsModal() {
</div> </div>
</aside> </aside>
<div className="absolute right-4 top-4 z-10"> <div className="absolute top-4 right-4 z-10">
<Tooltip label="Close settings" anchorToCursor> <Tooltip label="Close settings" anchorToCursor>
<button <button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
@@ -115,5 +121,5 @@ export function SettingsModal() {
</main> </main>
</div> </div>
</div> </div>
); )
} }
+29 -29
View File
@@ -1,54 +1,54 @@
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { Tooltip } from "./Tooltip"; import { Tooltip } from './Tooltip'
import { PlusIcon } from "./icons"; import { PlusIcon } from './icons'
import { AlbumSection } from "./sidebar/AlbumSection"; import { AlbumSection } from './sidebar/AlbumSection'
import { LibrarySection } from "./sidebar/LibrarySection"; import { LibrarySection } from './sidebar/LibrarySection'
import { NavItem } from "./sidebar/NavItem"; import { NavItem } from './sidebar/NavItem'
export function Sidebar() { export function Sidebar() {
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const selectFolder = useGalleryStore((state) => state.selectFolder); const selectFolder = useGalleryStore((state) => state.selectFolder)
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView)
const setView = useGalleryStore((state) => state.setView); const setView = useGalleryStore((state) => state.setView)
return ( return (
<aside className="w-52 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06] lg:w-60"> <aside className="flex w-52 shrink-0 flex-col border-r border-white/[0.06] bg-gray-950 lg:w-60">
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0"> <div className="flex h-12 shrink-0 items-center justify-between border-b border-white/[0.06] px-4">
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span> <span className="text-[13px] font-semibold tracking-wide text-white/80">Phokus</span>
<Tooltip label="Add Media Folder" anchorToCursor> <Tooltip label="Add Media Folder" anchorToCursor>
<button <button
onClick={() => setFolderPickerOpen(true)} onClick={() => setFolderPickerOpen(true)}
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-white/8 transition-colors" className="rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-white/8 hover:text-gray-200"
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<div className="px-2 pt-2 pb-1 space-y-px"> <div className="space-y-px px-2 pt-2 pb-1">
<NavItem <NavItem
label="All Media" label="All Media"
active={activeView === "gallery" && selectedFolderId === null} active={activeView === 'gallery' && selectedFolderId === null}
onClick={() => selectFolder(null)} onClick={() => selectFolder(null)}
iconPath="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" iconPath="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/> />
<NavItem <NavItem
label="Explore" label="Explore"
active={activeView === "explore"} active={activeView === 'explore'}
onClick={() => setView("explore")} onClick={() => setView('explore')}
iconPath="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" iconPath="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
/> />
<NavItem <NavItem
label="Timeline" label="Timeline"
active={activeView === "timeline"} active={activeView === 'timeline'}
onClick={() => setView("timeline")} onClick={() => setView('timeline')}
iconPath="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" iconPath="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/> />
<NavItem <NavItem
label="Duplicates" label="Duplicates"
active={activeView === "duplicates"} active={activeView === 'duplicates'}
onClick={() => setView("duplicates")} onClick={() => setView('duplicates')}
iconPath="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" iconPath="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/> />
</div> </div>
@@ -56,5 +56,5 @@ export function Sidebar() {
<LibrarySection /> <LibrarySection />
<AlbumSection /> <AlbumSection />
</aside> </aside>
); )
} }
+92 -99
View File
@@ -1,70 +1,69 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from '@tanstack/react-virtual'
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; import { ImageRecord, tileSizeForZoom, useGalleryStore } from '../store'
import { ImageTile } from "./gallery/ImageTile"; import { ImageTile } from './gallery/ImageTile'
import { ImageContextMenu } from "./ImageContextMenu"; import { ImageContextMenu } from './ImageContextMenu'
import { ScrubberYearBlock } from "./timeline/ScrubberYearBlock"; import { ScrubberYearBlock } from './timeline/ScrubberYearBlock'
import { TimelineEmptyState, TimelineLoadingState } from "./timeline/TimelineEmptyState"; import { TimelineEmptyState, TimelineLoadingState } from './timeline/TimelineEmptyState'
import { buildScrubberYears, buildTimelineRows, groupImages } from "./timeline/timelineModel"; import { buildScrubberYears, buildTimelineRows, groupImages } from './timeline/timelineModel'
const GAP = 6; const GAP = 6
const HEADER_HEIGHT = 52; const HEADER_HEIGHT = 52
const SCRUBBER_WIDTH = 48; const SCRUBBER_WIDTH = 48
export function Timeline() { export function Timeline() {
const images = useGalleryStore((s) => s.images); const images = useGalleryStore((s) => s.images)
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages); const loadMoreImages = useGalleryStore((s) => s.loadMoreImages)
const openImage = useGalleryStore((s) => s.openImage); const openImage = useGalleryStore((s) => s.openImage)
const totalImages = useGalleryStore((s) => s.totalImages); const totalImages = useGalleryStore((s) => s.totalImages)
const loadingImages = useGalleryStore((s) => s.loadingImages); const loadingImages = useGalleryStore((s) => s.loadingImages)
const imageLoadError = useGalleryStore((s) => s.imageLoadError); const imageLoadError = useGalleryStore((s) => s.imageLoadError)
const zoomPreset = useGalleryStore((s) => s.zoomPreset); const zoomPreset = useGalleryStore((s) => s.zoomPreset)
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0)
const [activeGroupIndex, setActiveGroupIndex] = useState(0); const [activeGroupIndex, setActiveGroupIndex] = useState(0)
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
x: number; x: number
y: number; y: number
image: ImageRecord; image: ImageRecord
} | null>(null); } | null>(null)
// parentRef is the scroll container. Its clientWidth already excludes the // parentRef is the scroll container. Its clientWidth already excludes the
// scrubber because they are flex siblings, so no further adjustment is needed. // scrubber because they are flex siblings, so no further adjustment is needed.
useLayoutEffect(() => { useLayoutEffect(() => {
const el = parentRef.current; const el = parentRef.current
if (!el) return; if (!el) return
setContainerWidth(el.clientWidth); setContainerWidth(el.clientWidth)
const ro = new ResizeObserver((entries) => { const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width); setContainerWidth(entries[0].contentRect.width)
}); })
ro.observe(el); ro.observe(el)
return () => ro.disconnect(); return () => ro.disconnect()
}, []); }, [])
const tileSize = tileSizeForZoom(zoomPreset); const tileSize = tileSizeForZoom(zoomPreset)
const groups = useMemo(() => groupImages(images), [images]); const groups = useMemo(() => groupImages(images), [images])
const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]); const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups])
// Show as soon as there's more than one month to jump between — not gated on // Show as soon as there's more than one month to jump between — not gated on
// a full year. With taken_asc sort the loaded set is oldest-first, so this // a full year. With taken_asc sort the loaded set is oldest-first, so this
// reflects whatever range is currently loaded. // reflects whatever range is currently loaded.
const showScrubber = groups.length > 1; const showScrubber = groups.length > 1
const cols = useMemo( const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))), () => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize], [containerWidth, tileSize]
); )
const { rows, rowToGroupIndex, groupFirstRow } = useMemo( const { rows, rowToGroupIndex, groupFirstRow } = useMemo(
() => buildTimelineRows(groups, cols), () => buildTimelineRows(groups, cols),
[groups, cols], [groups, cols]
); )
const estimateSize = useCallback( const estimateSize = useCallback(
(index: number): number => (index: number): number => (rows[index]?.type === 'header' ? HEADER_HEIGHT : tileSize + GAP),
rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP, [rows, tileSize]
[rows, tileSize], )
);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rows.length, count: rows.length,
@@ -72,107 +71,101 @@ export function Timeline() {
estimateSize, estimateSize,
overscan: 6, overscan: 6,
paddingStart: GAP, paddingStart: GAP,
}); })
// Refs so the scroll handler can read the latest mappings without re-binding. // Refs so the scroll handler can read the latest mappings without re-binding.
const rowToGroupIndexRef = useRef(rowToGroupIndex); const rowToGroupIndexRef = useRef(rowToGroupIndex)
rowToGroupIndexRef.current = rowToGroupIndex; rowToGroupIndexRef.current = rowToGroupIndex
const groupFirstRowRef = useRef(groupFirstRow); const groupFirstRowRef = useRef(groupFirstRow)
groupFirstRowRef.current = groupFirstRow; groupFirstRowRef.current = groupFirstRow
useEffect(() => { useEffect(() => {
virtualizer.measure(); virtualizer.measure()
}, [cols, virtualizer]); }, [cols, virtualizer])
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = parentRef.current; const el = parentRef.current
if (!el) return; if (!el) return
// Active month = the group owning the first row still visible at the top. // Active month = the group owning the first row still visible at the top.
const scrollTop = el.scrollTop; const scrollTop = el.scrollTop
const items = virtualizer.getVirtualItems(); const items = virtualizer.getVirtualItems()
let activeRow = items.length > 0 ? items[0].index : 0; let activeRow = items.length > 0 ? items[0].index : 0
for (const item of items) { for (const item of items) {
if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) { if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
activeRow = item.index; activeRow = item.index
break; break
} }
} }
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0); setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0)
if (scrollTop < 24) return; if (scrollTop < 24) return
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600; const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600
if (nearBottom && !loadingImages && images.length < totalImages) { if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages(); void loadMoreImages()
} }
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]); }, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages])
useEffect(() => { useEffect(() => {
const el = parentRef.current; const el = parentRef.current
if (!el) return; if (!el) return
el.addEventListener("scroll", handleScroll, { passive: true }); el.addEventListener('scroll', handleScroll, { passive: true })
return () => el.removeEventListener("scroll", handleScroll); return () => el.removeEventListener('scroll', handleScroll)
}, [handleScroll]); }, [handleScroll])
const scrollToGroup = useCallback( const scrollToGroup = useCallback(
(groupIndex: number) => { (groupIndex: number) => {
const row = groupFirstRowRef.current[groupIndex] ?? 0; const row = groupFirstRowRef.current[groupIndex] ?? 0
virtualizer.scrollToIndex(row, { align: "start" }); virtualizer.scrollToIndex(row, { align: 'start' })
}, },
[virtualizer], [virtualizer]
); )
return ( return (
// Outer flex-row: fills remaining height in <main>'s flex-col, then // Outer flex-row: fills remaining height in <main>'s flex-col, then
// splits horizontally between the scroll area and the scrubber. // splits horizontally between the scroll area and the scrubber.
<div className="flex flex-1 min-h-0 bg-gray-950"> <div className="flex min-h-0 flex-1 bg-gray-950">
{/* Scroll container — flex-1 takes all width except the scrubber */} {/* Scroll container — flex-1 takes all width except the scrubber */}
<div <div ref={parentRef} className="relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
ref={parentRef}
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0"
>
{images.length === 0 && loadingImages ? ( {images.length === 0 && loadingImages ? (
<TimelineLoadingState /> <TimelineLoadingState />
) : images.length === 0 ? ( ) : images.length === 0 ? (
<TimelineEmptyState imageLoadError={imageLoadError} /> <TimelineEmptyState imageLoadError={imageLoadError} />
) : ( ) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}> <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => { {virtualizer.getVirtualItems().map((virtualItem) => {
const row = rows[virtualItem.index]; const row = rows[virtualItem.index]
if (!row) return null; if (!row) return null
return ( return (
<div <div
key={virtualItem.key} key={virtualItem.key}
style={{ style={{
position: "absolute", position: 'absolute',
top: virtualItem.start, top: virtualItem.start,
width: "100%", width: '100%',
height: virtualItem.size, height: virtualItem.size,
}} }}
> >
{row.type === "header" ? ( {row.type === 'header' ? (
<div <div className="flex items-center gap-3 px-4" style={{ height: HEADER_HEIGHT }}>
className="flex items-center gap-3 px-4" <span className="shrink-0 text-sm font-semibold text-white/80">
style={{ height: HEADER_HEIGHT }}
>
<span className="text-sm font-semibold text-white/80 shrink-0">
{row.group.label} {row.group.label}
</span> </span>
<span className="text-xs text-white/25 shrink-0 tabular-nums"> <span className="shrink-0 text-xs text-white/25 tabular-nums">
{row.group.images.length} {row.group.images.length}
</span> </span>
<div className="flex-1 h-px bg-white/[0.06]" /> <div className="h-px flex-1 bg-white/[0.06]" />
</div> </div>
) : ( ) : (
<div <div
style={{ style={{
display: "grid", display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`, gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP, gap: GAP,
paddingLeft: GAP, paddingLeft: GAP,
paddingRight: GAP, paddingRight: GAP,
paddingBottom: GAP, paddingBottom: GAP,
boxSizing: "border-box", boxSizing: 'border-box',
}} }}
> >
{row.images.map((image) => ( {row.images.map((image) => (
@@ -181,22 +174,22 @@ export function Timeline() {
image={image} image={image}
onClick={() => openImage(image)} onClick={() => openImage(image)}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault(); event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY, image }); setContextMenu({ x: event.clientX, y: event.clientY, image })
}} }}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
); )
})} })}
</div> </div>
)} )}
{images.length > 0 && loadingImages ? ( {images.length > 0 && loadingImages ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -204,7 +197,7 @@ export function Timeline() {
{/* Scrubber — flex sibling so it stays visible while the left panel scrolls */} {/* Scrubber — flex sibling so it stays visible while the left panel scrolls */}
{showScrubber ? ( {showScrubber ? (
<div <div
className="overflow-y-auto border-l border-white/[0.04] py-2 flex flex-col items-center gap-0.5" className="flex flex-col items-center gap-0.5 overflow-y-auto border-l border-white/[0.04] py-2"
style={{ width: SCRUBBER_WIDTH }} style={{ width: SCRUBBER_WIDTH }}
> >
{scrubberYears.map((yearEntry) => ( {scrubberYears.map((yearEntry) => (
@@ -227,5 +220,5 @@ export function Timeline() {
/> />
) : null} ) : null}
</div> </div>
); )
} }
+90 -65
View File
@@ -1,15 +1,15 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from 'react'
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from '@tauri-apps/api/window'
import { useGalleryStore, AppTheme } from "../store"; import { useGalleryStore, AppTheme } from '../store'
import { ContextMenu, MenuItem, MenuLabel } from "./menu"; import { ContextMenu, MenuItem, MenuLabel } from './menu'
import { PhokusMark } from "./PhokusMark"; import { PhokusMark } from './PhokusMark'
import { Tooltip } from "./Tooltip"; import { Tooltip } from './Tooltip'
const THEME_OPTIONS: { value: AppTheme; label: string }[] = [ const THEME_OPTIONS: { value: AppTheme; label: string }[] = [
{ value: "phokus", label: "Phokus" }, { value: 'phokus', label: 'Phokus' },
{ value: "subtle-light", label: "Subtle Light" }, { value: 'subtle-light', label: 'Subtle Light' },
{ value: "conventional-dark", label: "Conventional Dark" }, { value: 'conventional-dark', label: 'Conventional Dark' },
]; ]
// SVG icons for window controls // SVG icons for window controls
function MinimizeIcon() { function MinimizeIcon() {
@@ -17,7 +17,7 @@ function MinimizeIcon() {
<svg width="10" height="2" viewBox="0 0 10 2" fill="none"> <svg width="10" height="2" viewBox="0 0 10 2" fill="none">
<rect y="0.5" width="10" height="1" rx="0.5" fill="currentColor" /> <rect y="0.5" width="10" height="1" rx="0.5" fill="currentColor" />
</svg> </svg>
); )
} }
function MaximizeIcon() { function MaximizeIcon() {
@@ -25,16 +25,25 @@ function MaximizeIcon() {
<svg width="10" height="10" viewBox="0 0 10 10" fill="none"> <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<rect x="0.5" y="0.5" width="9" height="9" rx="1.5" stroke="currentColor" strokeWidth="1" /> <rect x="0.5" y="0.5" width="9" height="9" rx="1.5" stroke="currentColor" strokeWidth="1" />
</svg> </svg>
); )
} }
function RestoreIcon() { function RestoreIcon() {
return ( return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none"> <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<rect x="2.5" y="0.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" /> <rect x="2.5" y="0.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="var(--color-gray-950)" /> <rect
x="0.5"
y="2.5"
width="7"
height="7"
rx="1.5"
stroke="currentColor"
strokeWidth="1"
fill="var(--color-gray-950)"
/>
</svg> </svg>
); )
} }
function CloseIcon() { function CloseIcon() {
@@ -42,52 +51,52 @@ function CloseIcon() {
<svg width="10" height="10" viewBox="0 0 10 10" fill="none"> <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" /> <path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg> </svg>
); )
} }
export function TitleBar() { export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false)
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen); const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen)
const theme = useGalleryStore((state) => state.theme); const theme = useGalleryStore((state) => state.theme)
const setTheme = useGalleryStore((state) => state.setTheme); const setTheme = useGalleryStore((state) => state.setTheme)
const updateStatus = useGalleryStore((state) => state.updateStatus); const updateStatus = useGalleryStore((state) => state.updateStatus)
const updateVersion = useGalleryStore((state) => state.updateVersion); const updateVersion = useGalleryStore((state) => state.updateVersion)
const installUpdate = useGalleryStore((state) => state.installUpdate); const installUpdate = useGalleryStore((state) => state.installUpdate)
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow()
// Right-clicking the settings cog opens a quick theme switcher, anchored under // Right-clicking the settings cog opens a quick theme switcher, anchored under
// the cog so it never overflows the right edge of the window. // the cog so it never overflows the right edge of the window.
const settingsBtnRef = useRef<HTMLButtonElement>(null); const settingsBtnRef = useRef<HTMLButtonElement>(null)
const [themeMenu, setThemeMenu] = useState<{ x: number; y: number } | null>(null); const [themeMenu, setThemeMenu] = useState<{ x: number; y: number } | null>(null)
const handleSettingsContextMenu = (e: React.MouseEvent) => { const handleSettingsContextMenu = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault()
const rect = settingsBtnRef.current?.getBoundingClientRect(); const rect = settingsBtnRef.current?.getBoundingClientRect()
if (!rect) return; if (!rect) return
setThemeMenu({ x: rect.right, y: rect.bottom + 4 }); setThemeMenu({ x: rect.right, y: rect.bottom + 4 })
}; }
useEffect(() => { useEffect(() => {
// Get initial maximized state // Get initial maximized state
appWindow.isMaximized().then(setIsMaximized); appWindow.isMaximized().then(setIsMaximized)
// Listen for resize events to update maximized state // Listen for resize events to update maximized state
const unlisten = appWindow.onResized(() => { const unlisten = appWindow.onResized(() => {
appWindow.isMaximized().then(setIsMaximized); appWindow.isMaximized().then(setIsMaximized)
}); })
return () => { return () => {
unlisten.then((fn) => fn()); unlisten.then((fn) => fn())
}; }
}, []); }, [])
const handleMinimize = () => appWindow.minimize(); const handleMinimize = () => appWindow.minimize()
const handleMaximize = () => appWindow.toggleMaximize(); const handleMaximize = () => appWindow.toggleMaximize()
const handleClose = () => appWindow.close(); const handleClose = () => appWindow.close()
// An update is waiting for the user to act. Covers the "clicked Later" case too, // An update is waiting for the user to act. Covers the "clicked Later" case too,
// since dismissing the toast doesn't change updateStatus. // since dismissing the toast doesn't change updateStatus.
const updatePending = updateStatus === "available"; const updatePending = updateStatus === 'available'
return ( return (
// data-tauri-drag-region is the recommended Tauri approach for drag regions. // data-tauri-drag-region is the recommended Tauri approach for drag regions.
@@ -95,31 +104,31 @@ export function TitleBar() {
<div <div
data-tauri-drag-region data-tauri-drag-region
className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none" className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties} style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
> >
{/* App icon + name — left side. When an update is waiting, the iris lights {/* App icon + name — left side. When an update is waiting, the iris lights
up its focal point and the chip becomes a button that re-opens the prompt. */} up its focal point and the chip becomes a button that re-opens the prompt. */}
<div className="flex items-center gap-2 pl-3 pr-4"> <div className="flex items-center gap-2 pr-4 pl-3">
{updatePending ? ( {updatePending ? (
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}> <div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{/* Instant tooltip (delay 0) — this affordance should read immediately. */} {/* Instant tooltip (delay 0) — this affordance should read immediately. */}
<Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start"> <Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
<button <button
onClick={() => void installUpdate()} onClick={() => void installUpdate()}
aria-label={`Update available — click to update to Phokus v${updateVersion}`} aria-label={`Update available — click to update to Phokus v${updateVersion}`}
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12" className="relative flex h-5 w-5 items-center justify-center overflow-hidden rounded-md bg-white/8 text-gray-300 transition-colors hover:bg-white/12"
> >
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" /> <span className="pointer-events-none absolute top-1/2 left-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-amber-400/60" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" /> <PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
) : ( ) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300"> <div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-md bg-white/8 text-gray-300">
<PhokusMark className="h-4 w-4" /> <PhokusMark className="h-4 w-4" />
</div> </div>
)} )}
<span className="text-[11px] font-semibold text-gray-400 tracking-wide">Phokus</span> <span className="text-[11px] font-semibold tracking-wide text-gray-400">Phokus</span>
</div> </div>
{/* Spacer — draggable region fills here */} {/* Spacer — draggable region fills here */}
@@ -127,23 +136,33 @@ export function TitleBar() {
{/* Window control buttons — right side, non-draggable */} {/* Window control buttons — right side, non-draggable */}
<div <div
className="flex items-stretch h-full" className="flex h-full items-stretch"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
> >
<Tooltip label="Settings (right-click to switch theme)" anchorToCursor> <Tooltip label="Settings (right-click to switch theme)" anchorToCursor>
<button <button
ref={settingsBtnRef} ref={settingsBtnRef}
aria-label="Settings" aria-label="Settings"
onClick={() => setSettingsOpen(true)} onClick={() => setSettingsOpen(true)}
onContextMenu={handleSettingsContextMenu} onContextMenu={handleSettingsContextMenu}
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300" className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.607 2.296.07 2.572-1.065z" /> <path
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> strokeLinecap="round"
</svg> strokeLinejoin="round"
</button> strokeWidth={1.8}
</Tooltip> d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.607 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</Tooltip>
{/* Minimize */} {/* Minimize */}
<button <button
onClick={handleMinimize} onClick={handleMinimize}
@@ -156,7 +175,7 @@ export function TitleBar() {
{/* Maximize / Restore */} {/* Maximize / Restore */}
<button <button
onClick={handleMaximize} onClick={handleMaximize}
title={isMaximized ? "Restore" : "Maximize"} title={isMaximized ? 'Restore' : 'Maximize'}
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300" className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
> >
{isMaximized ? <RestoreIcon /> : <MaximizeIcon />} {isMaximized ? <RestoreIcon /> : <MaximizeIcon />}
@@ -174,7 +193,13 @@ export function TitleBar() {
{/* Quick theme switcher — opened by right-clicking the settings cog. */} {/* Quick theme switcher — opened by right-clicking the settings cog. */}
{themeMenu && ( {themeMenu && (
<ContextMenu x={themeMenu.x} y={themeMenu.y} size="sm" align="end" onClose={() => setThemeMenu(null)}> <ContextMenu
x={themeMenu.x}
y={themeMenu.y}
size="sm"
align="end"
onClose={() => setThemeMenu(null)}
>
<MenuLabel>Theme</MenuLabel> <MenuLabel>Theme</MenuLabel>
{THEME_OPTIONS.map((opt) => ( {THEME_OPTIONS.map((opt) => (
<MenuItem <MenuItem
@@ -187,5 +212,5 @@ export function TitleBar() {
</ContextMenu> </ContextMenu>
)} )}
</div> </div>
); )
} }
+8 -8
View File
@@ -1,12 +1,12 @@
import { SortControl } from "./toolbar/SortControl"; import { SortControl } from './toolbar/SortControl'
import { ToolbarFilters } from "./toolbar/ToolbarFilters"; import { ToolbarFilters } from './toolbar/ToolbarFilters'
import { ToolbarSearch } from "./toolbar/ToolbarSearch"; import { ToolbarSearch } from './toolbar/ToolbarSearch'
import { ToolbarTitle } from "./toolbar/ToolbarTitle"; import { ToolbarTitle } from './toolbar/ToolbarTitle'
import { useToolbarSearch } from "./toolbar/useToolbarSearch"; import { useToolbarSearch } from './toolbar/useToolbarSearch'
import { ZoomControl } from "./toolbar/ZoomControl"; import { ZoomControl } from './toolbar/ZoomControl'
export function Toolbar() { export function Toolbar() {
const searchState = useToolbarSearch(); const searchState = useToolbarSearch()
return ( return (
<div className="relative z-40 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl"> <div className="relative z-40 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
@@ -20,5 +20,5 @@ export function Toolbar() {
</div> </div>
<ToolbarFilters /> <ToolbarFilters />
</div> </div>
); )
} }
+84 -77
View File
@@ -1,22 +1,23 @@
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react"; import { MouseEvent, ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom'
import { motion } from "framer-motion"; import { motion } from 'framer-motion'
type TooltipSide = "top" | "bottom" | "left" | "right"; type TooltipSide = 'top' | 'bottom' | 'left' | 'right'
type TooltipAlign = "center" | "start" | "end"; type TooltipAlign = 'center' | 'start' | 'end'
// Horizontal alignment only applies to top/bottom; left/right center vertically. // Horizontal alignment only applies to top/bottom; left/right center vertically.
function positionClasses(side: TooltipSide, align: TooltipAlign): string { function positionClasses(side: TooltipSide, align: TooltipAlign): string {
if (side === "left") return "right-full top-1/2 mr-1.5 -translate-y-1/2"; if (side === 'left') return 'right-full top-1/2 mr-1.5 -translate-y-1/2'
if (side === "right") return "left-full top-1/2 ml-1.5 -translate-y-1/2"; if (side === 'right') return 'left-full top-1/2 ml-1.5 -translate-y-1/2'
const vertical = side === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5"; const vertical = side === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
const horizontal = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2"; const horizontal =
return `${vertical} ${horizontal}`; align === 'start' ? 'left-0' : align === 'end' ? 'right-0' : 'left-1/2 -translate-x-1/2'
return `${vertical} ${horizontal}`
} }
const BASE_CLASSES = const BASE_CLASSES =
"pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg"; 'pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg'
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`; const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`
/** /**
* Lightweight custom tooltip — fades in (faster than the native one) after a * Lightweight custom tooltip — fades in (faster than the native one) after a
@@ -27,98 +28,98 @@ const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
export function Tooltip({ export function Tooltip({
label, label,
delay = 400, delay = 400,
side = "bottom", side = 'bottom',
align = "center", align = 'center',
block = false, block = false,
anchorToCursor = false, anchorToCursor = false,
followCursor = false, followCursor = false,
disabled = false, disabled = false,
className = "", className = '',
children, children,
}: { }: {
label: ReactNode; label: ReactNode
/** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */ /** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */
delay?: number; delay?: number
side?: TooltipSide; side?: TooltipSide
/** Horizontal alignment for top/bottom tooltips. */ /** Horizontal alignment for top/bottom tooltips. */
align?: TooltipAlign; align?: TooltipAlign
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */ /** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean; block?: boolean
/** Position at the cursor when shown, without following subsequent movement. */ /** Position at the cursor when shown, without following subsequent movement. */
anchorToCursor?: boolean; anchorToCursor?: boolean
/** Track the cursor (fixed position) instead of anchoring to a side. */ /** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean; followCursor?: boolean
/** Render the wrapper but suppress tooltip display. */ /** Render the wrapper but suppress tooltip display. */
disabled?: boolean; disabled?: boolean
/** Extra classes for the wrapper (e.g. layout). */ /** Extra classes for the wrapper (e.g. layout). */
className?: string; className?: string
children: ReactNode; children: ReactNode
}) { }) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false)
const [coords, setCoords] = useState({ x: 0, y: 0 }); const [coords, setCoords] = useState({ x: 0, y: 0 })
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const frame = useRef<number | undefined>(undefined); const frame = useRef<number | undefined>(undefined)
const tooltipRef = useRef<HTMLSpanElement>(null); const tooltipRef = useRef<HTMLSpanElement>(null)
// Resolve a viewport-safe position near the cursor: below-right by default, // Resolve a viewport-safe position near the cursor: below-right by default,
// but flipped above the cursor if it would overflow the bottom edge, and // but flipped above the cursor if it would overflow the bottom edge, and
// clamped so it never runs off the right/left. // clamped so it never runs off the right/left.
const place = (clientX: number, clientY: number) => { const place = (clientX: number, clientY: number) => {
const tip = tooltipRef.current; const tip = tooltipRef.current
const tipWidth = tip?.offsetWidth ?? 0; const tipWidth = tip?.offsetWidth ?? 0
const tipHeight = tip?.offsetHeight ?? 24; const tipHeight = tip?.offsetHeight ?? 24
const gap = 16; const gap = 16
let top = clientY + gap; let top = clientY + gap
if (top + tipHeight > window.innerHeight - 4) { if (top + tipHeight > window.innerHeight - 4) {
top = clientY - gap - tipHeight; top = clientY - gap - tipHeight
} }
let left = clientX + 14; let left = clientX + 14
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth; if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth
if (left < 4) left = 4; if (left < 4) left = 4
setCoords({ x: left, y: top }); setCoords({ x: left, y: top })
}; }
const clear = () => { const clear = () => {
if (timer.current) clearTimeout(timer.current); if (timer.current) clearTimeout(timer.current)
timer.current = undefined; timer.current = undefined
if (frame.current !== undefined) cancelAnimationFrame(frame.current); if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = undefined; frame.current = undefined
}; }
const show = (event: MouseEvent<HTMLElement>) => { const show = (event: MouseEvent<HTMLElement>) => {
if (disabled) return; if (disabled) return
clear(); clear()
if (anchorToCursor || followCursor) place(event.clientX, event.clientY); if (anchorToCursor || followCursor) place(event.clientX, event.clientY)
if (delay <= 0) { if (delay <= 0) {
setVisible(true); setVisible(true)
return; return
} }
timer.current = setTimeout(() => setVisible(true), delay); timer.current = setTimeout(() => setVisible(true), delay)
}; }
const hide = () => { const hide = () => {
clear(); clear()
setVisible(false); setVisible(false)
}; }
const move = (event: MouseEvent<HTMLElement>) => { const move = (event: MouseEvent<HTMLElement>) => {
if (!followCursor) return; if (!followCursor) return
const { clientX, clientY } = event; const { clientX, clientY } = event
if (frame.current !== undefined) cancelAnimationFrame(frame.current); if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = requestAnimationFrame(() => { frame.current = requestAnimationFrame(() => {
frame.current = undefined; frame.current = undefined
place(clientX, clientY); place(clientX, clientY)
}); })
}; }
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true)
return clear; return clear
}, []); }, [])
useEffect(() => { useEffect(() => {
if (disabled) hide(); if (disabled) hide()
}, [disabled]); }, [disabled])
const Wrapper = block ? "div" : "span"; const Wrapper = block ? 'div' : 'span'
const cursorTooltip = ( const cursorTooltip = (
<motion.span <motion.span
ref={tooltipRef} ref={tooltipRef}
@@ -130,18 +131,22 @@ export function Tooltip({
initial={false} initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }} animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{ transition={{
left: followCursor ? { type: "spring", stiffness: 700, damping: 45, mass: 0.35 } : { duration: 0 }, left: followCursor
top: followCursor ? { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } : { duration: 0 }, ? { type: 'spring', stiffness: 700, damping: 45, mass: 0.35 }
: { duration: 0 },
top: followCursor
? { type: 'spring', stiffness: 520, damping: 34, mass: 0.45 }
: { duration: 0 },
opacity: { duration: visible ? 0.1 : 0.05 }, opacity: { duration: visible ? 0.1 : 0.05 },
}} }}
> >
{label} {label}
</motion.span> </motion.span>
); )
return ( return (
<Wrapper <Wrapper
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`} className={`${block ? 'relative block w-full' : 'relative inline-flex'} ${className}`}
onMouseEnter={show} onMouseEnter={show}
onMouseMove={followCursor ? move : undefined} onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide} onMouseLeave={hide}
@@ -149,16 +154,18 @@ export function Tooltip({
> >
{children} {children}
{disabled ? null : anchorToCursor || followCursor ? ( {disabled ? null : anchorToCursor || followCursor ? (
mounted ? createPortal(cursorTooltip, document.body) : null mounted ? (
createPortal(cursorTooltip, document.body)
) : null
) : ( ) : (
<span <span
role="tooltip" role="tooltip"
aria-hidden={!visible} aria-hidden={!visible}
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`} className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? 'opacity-100' : 'opacity-0'}`}
> >
{label} {label}
</span> </span>
)} )}
</Wrapper> </Wrapper>
); )
} }
+21 -15
View File
@@ -1,17 +1,19 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
export function UpdateToast() { export function UpdateToast() {
const updateStatus = useGalleryStore((s) => s.updateStatus); const updateStatus = useGalleryStore((s) => s.updateStatus)
const updateVersion = useGalleryStore((s) => s.updateVersion); const updateVersion = useGalleryStore((s) => s.updateVersion)
const updateProgress = useGalleryStore((s) => s.updateProgress); const updateProgress = useGalleryStore((s) => s.updateProgress)
const updateDismissed = useGalleryStore((s) => s.updateDismissed); const updateDismissed = useGalleryStore((s) => s.updateDismissed)
const installUpdate = useGalleryStore((s) => s.installUpdate); const installUpdate = useGalleryStore((s) => s.installUpdate)
const dismissUpdate = useGalleryStore((s) => s.dismissUpdate); const dismissUpdate = useGalleryStore((s) => s.dismissUpdate)
const visible = const visible =
!updateDismissed && !updateDismissed &&
(updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing"); (updateStatus === 'available' ||
updateStatus === 'downloading' ||
updateStatus === 'installing')
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -21,9 +23,9 @@ export function UpdateToast() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }} exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.18 }} transition={{ duration: 0.18 }}
className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-white/10 bg-gray-900 p-4 shadow-xl" className="fixed right-4 bottom-4 z-50 w-80 rounded-lg border border-white/10 bg-gray-900 p-4 shadow-xl"
> >
{updateStatus === "available" ? ( {updateStatus === 'available' ? (
<> <>
<p className="text-sm font-medium text-white">Update available</p> <p className="text-sm font-medium text-white">Update available</p>
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
@@ -47,14 +49,18 @@ export function UpdateToast() {
) : ( ) : (
<> <>
<p className="text-sm font-medium text-white"> <p className="text-sm font-medium text-white">
{updateStatus === "installing" ? "Installing update..." : "Downloading update..."} {updateStatus === 'installing' ? 'Installing update...' : 'Downloading update...'}
</p> </p>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-white/10"> <div className="mt-3 h-1 overflow-hidden rounded-full bg-white/10">
<div <div
className={`h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${ className={`h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${
updateProgress === null ? "w-full animate-pulse" : "" updateProgress === null ? 'w-full animate-pulse' : ''
}`} }`}
style={updateProgress !== null ? { width: `${Math.round(updateProgress * 100)}%` } : undefined} style={
updateProgress !== null
? { width: `${Math.round(updateProgress * 100)}%` }
: undefined
}
/> />
</div> </div>
<p className="mt-2 text-xs text-gray-600">The app will restart when it finishes.</p> <p className="mt-2 text-xs text-gray-600">The app will restart when it finishes.</p>
@@ -63,5 +69,5 @@ export function UpdateToast() {
</motion.div> </motion.div>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
); )
} }
+11 -11
View File
@@ -1,13 +1,13 @@
import { VideoControls } from "./videoPlayer/VideoControls"; import { VideoControls } from './videoPlayer/VideoControls'
import { useVideoPlayer } from "./videoPlayer/useVideoPlayer"; import { useVideoPlayer } from './videoPlayer/useVideoPlayer'
export function VideoPlayer({ src }: { src: string }) { export function VideoPlayer({ src }: { src: string }) {
const player = useVideoPlayer(src); const player = useVideoPlayer(src)
return ( return (
<div <div
ref={player.containerRef} ref={player.containerRef}
className={`media-dark-surface relative flex h-full w-full items-center justify-center bg-black ${player.controlsVisible ? "" : "cursor-none"}`} className={`media-dark-surface relative flex h-full w-full items-center justify-center bg-black ${player.controlsVisible ? '' : 'cursor-none'}`}
onPointerMove={player.showControls} onPointerMove={player.showControls}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
@@ -18,17 +18,17 @@ export function VideoPlayer({ src }: { src: string }) {
onClick={player.togglePlay} onClick={player.togglePlay}
onDoubleClick={player.toggleFullscreen} onDoubleClick={player.toggleFullscreen}
onPlay={() => { onPlay={() => {
player.setPlaying(true); player.setPlaying(true)
player.showControls(); player.showControls()
}} }}
onPause={() => { onPause={() => {
player.setPlaying(false); player.setPlaying(false)
player.setControlsVisible(true); player.setControlsVisible(true)
}} }}
onTimeUpdate={(event) => player.setCurrentTime(event.currentTarget.currentTime)} onTimeUpdate={(event) => player.setCurrentTime(event.currentTarget.currentTime)}
onLoadedMetadata={(event) => { onLoadedMetadata={(event) => {
player.setDuration(event.currentTarget.duration); player.setDuration(event.currentTarget.duration)
player.readBuffered(); player.readBuffered()
}} }}
onDurationChange={(event) => player.setDuration(event.currentTarget.duration)} onDurationChange={(event) => player.setDuration(event.currentTarget.duration)}
onProgress={player.readBuffered} onProgress={player.readBuffered}
@@ -60,5 +60,5 @@ export function VideoPlayer({ src }: { src: string }) {
trackRef={player.trackRef} trackRef={player.trackRef}
/> />
</div> </div>
); )
} }
+60 -50
View File
@@ -1,14 +1,14 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from 'react'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { invoke } from "@tauri-apps/api/core"; import { invoke } from '@tauri-apps/api/core'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { getChangelogForVersion, type ChangelogSection } from "../changelog"; import { getChangelogForVersion, type ChangelogSection } from '../changelog'
import { Tooltip } from "./Tooltip"; import { Tooltip } from './Tooltip'
import { ChevronRightIcon, CloseIcon } from "./icons"; import { ChevronRightIcon, CloseIcon } from './icons'
// Shown in the tooltip so the user can see the link's destination before // Shown in the tooltip so the user can see the link's destination before
// clicking. The actual navigation is handled by the open_changelog_url command. // clicking. The actual navigation is handled by the open_changelog_url command.
const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md"; const CHANGELOG_URL = 'https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md'
// Per-section accent. These all use the standard colour scale, which the // Per-section accent. These all use the standard colour scale, which the
// subtle-light theme re-maps to readable dark shades via CSS variables — so no // subtle-light theme re-maps to readable dark shades via CSS variables — so no
@@ -16,51 +16,51 @@ const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md"
// index.css: `bg-white`/`text-white` deliberately become *dark* in light mode, // index.css: `bg-white`/`text-white` deliberately become *dark* in light mode,
// so neutral surfaces must use the gray scale and trust the remap. // so neutral surfaces must use the gray scale and trust the remap.
const SECTION_STYLES: Record<string, { label: string; dot: string }> = { const SECTION_STYLES: Record<string, { label: string; dot: string }> = {
Added: { label: "text-emerald-300", dot: "bg-emerald-400/80" }, Added: { label: 'text-emerald-300', dot: 'bg-emerald-400/80' },
Changed: { label: "text-sky-300", dot: "bg-sky-300/80" }, Changed: { label: 'text-sky-300', dot: 'bg-sky-300/80' },
Fixed: { label: "text-amber-300", dot: "bg-amber-400/80" }, Fixed: { label: 'text-amber-300', dot: 'bg-amber-400/80' },
Removed: { label: "text-rose-300", dot: "bg-rose-400/80" }, Removed: { label: 'text-rose-300', dot: 'bg-rose-400/80' },
Security: { label: "text-violet-300", dot: "bg-violet-400/80" }, Security: { label: 'text-violet-300', dot: 'bg-violet-400/80' },
}; }
const NEUTRAL_STYLE = { label: "text-gray-300", dot: "bg-gray-400/70" }; const NEUTRAL_STYLE = { label: 'text-gray-300', dot: 'bg-gray-400/70' }
// Render the small amount of inline markdown the changelog uses: **bold** and // Render the small amount of inline markdown the changelog uses: **bold** and
// `code`. Anything else passes through as plain text. // `code`. Anything else passes through as plain text.
function renderInline(text: string): ReactNode[] { function renderInline(text: string): ReactNode[] {
const nodes: ReactNode[] = []; const nodes: ReactNode[] = []
const regex = /\*\*(.+?)\*\*|`([^`]+)`/g; const regex = /\*\*(.+?)\*\*|`([^`]+)`/g
let lastIndex = 0; let lastIndex = 0
let key = 0; let key = 0
let match: RegExpExecArray | null; let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) { while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) { if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index)); nodes.push(text.slice(lastIndex, match.index))
} }
if (match[1] !== undefined) { if (match[1] !== undefined) {
nodes.push( nodes.push(
<strong key={key++} className="font-semibold text-white"> <strong key={key++} className="font-semibold text-white">
{match[1]} {match[1]}
</strong>, </strong>
); )
} else if (match[2] !== undefined) { } else if (match[2] !== undefined) {
nodes.push( nodes.push(
<code key={key++} className="rounded bg-white/10 px-1 py-0.5 text-[12px] text-gray-200"> <code key={key++} className="rounded bg-white/10 px-1 py-0.5 text-[12px] text-gray-200">
{match[2]} {match[2]}
</code>, </code>
); )
} }
lastIndex = regex.lastIndex; lastIndex = regex.lastIndex
} }
if (lastIndex < text.length) { if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex)); nodes.push(text.slice(lastIndex))
} }
return nodes; return nodes
} }
function Section({ section }: { section: ChangelogSection }) { function Section({ section }: { section: ChangelogSection }) {
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE; const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true)
return ( return (
<section> <section>
@@ -70,15 +70,20 @@ function Section({ section }: { section: ChangelogSection }) {
className="flex w-full items-center gap-2 text-left" className="flex w-full items-center gap-2 text-left"
aria-expanded={open} aria-expanded={open}
> >
<ChevronRightIcon className={`h-3 w-3 shrink-0 text-gray-600 transition-transform ${open ? "rotate-90" : ""}`} strokeWidth={2.5} /> <ChevronRightIcon
<span className={`text-[11px] font-semibold uppercase tracking-[0.1em] ${style.label}`}>{section.title}</span> className={`h-3 w-3 shrink-0 text-gray-600 transition-transform ${open ? 'rotate-90' : ''}`}
strokeWidth={2.5}
/>
<span className={`text-[11px] font-semibold tracking-[0.1em] uppercase ${style.label}`}>
{section.title}
</span>
<span className="text-[11px] font-medium text-gray-600">{section.items.length}</span> <span className="text-[11px] font-medium text-gray-600">{section.items.length}</span>
</button> </button>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{open ? ( {open ? (
<motion.div <motion.div
initial={{ height: 0, opacity: 0 }} initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }} animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }} transition={{ duration: 0.18 }}
className="overflow-hidden" className="overflow-hidden"
@@ -104,24 +109,24 @@ function Section({ section }: { section: ChangelogSection }) {
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
</section> </section>
); )
} }
export function WhatsNewModal() { export function WhatsNewModal() {
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen); const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew); const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew)
const appVersion = useGalleryStore((s) => s.appVersion); const appVersion = useGalleryStore((s) => s.appVersion)
const entry = getChangelogForVersion(appVersion); const entry = getChangelogForVersion(appVersion)
useEffect(() => { useEffect(() => {
if (!whatsNewOpen) return; if (!whatsNewOpen) return
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeWhatsNew(); if (event.key === 'Escape') closeWhatsNew()
}; }
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown)
}, [whatsNewOpen, closeWhatsNew]); }, [whatsNewOpen, closeWhatsNew])
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -144,11 +149,15 @@ export function WhatsNewModal() {
> >
<div className="flex items-start justify-between gap-4 border-b border-white/[0.07] px-6 py-5"> <div className="flex items-start justify-between gap-4 border-b border-white/[0.07] px-6 py-5">
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-emerald-300">What's new</p> <p className="text-[11px] font-semibold tracking-[0.12em] text-emerald-300 uppercase">
What's new
</p>
<h3 className="mt-1 text-lg font-semibold text-white"> <h3 className="mt-1 text-lg font-semibold text-white">
Phokus v{entry?.version ?? appVersion ?? "—"} Phokus v{entry?.version ?? appVersion ?? ''}
</h3> </h3>
{entry?.date ? <p className="mt-0.5 text-xs text-gray-600">Released {entry.date}</p> : null} {entry?.date ? (
<p className="mt-0.5 text-xs text-gray-600">Released {entry.date}</p>
) : null}
</div> </div>
<Tooltip label="Close" anchorToCursor> <Tooltip label="Close" anchorToCursor>
<button <button
@@ -165,7 +174,8 @@ export function WhatsNewModal() {
entry.sections.map((section) => <Section key={section.title} section={section} />) entry.sections.map((section) => <Section key={section.title} section={section} />)
) : ( ) : (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Release notes for this version aren't available in-app. See the full changelog on GitHub. Release notes for this version aren't available in-app. See the full changelog on
GitHub.
</p> </p>
)} )}
</div> </div>
@@ -174,7 +184,7 @@ export function WhatsNewModal() {
<Tooltip label={CHANGELOG_URL} side="top" align="start"> <Tooltip label={CHANGELOG_URL} side="top" align="start">
<button <button
type="button" type="button"
onClick={() => void invoke("open_changelog_url")} onClick={() => void invoke('open_changelog_url')}
className="text-xs text-gray-500 transition-colors hover:text-gray-300" className="text-xs text-gray-500 transition-colors hover:text-gray-300"
> >
Full changelog Full changelog
@@ -191,5 +201,5 @@ export function WhatsNewModal() {
</motion.div> </motion.div>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
); )
} }
+9 -9
View File
@@ -1,15 +1,15 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
// Shown once on the first launch after an update, inviting the user to read the // Shown once on the first launch after an update, inviting the user to read the
// changelog. Distinct from UpdateToast (which drives the download/install flow). // changelog. Distinct from UpdateToast (which drives the download/install flow).
export function WhatsNewToast() { export function WhatsNewToast() {
const whatsNewToast = useGalleryStore((s) => s.whatsNewToast); const whatsNewToast = useGalleryStore((s) => s.whatsNewToast)
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen); const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
const openWhatsNew = useGalleryStore((s) => s.openWhatsNew); const openWhatsNew = useGalleryStore((s) => s.openWhatsNew)
const dismissWhatsNewToast = useGalleryStore((s) => s.dismissWhatsNewToast); const dismissWhatsNewToast = useGalleryStore((s) => s.dismissWhatsNewToast)
const visible = whatsNewToast !== null && !whatsNewOpen; const visible = whatsNewToast !== null && !whatsNewOpen
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -19,7 +19,7 @@ export function WhatsNewToast() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }} exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.18 }} transition={{ duration: 0.18 }}
className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-emerald-400/20 bg-gray-900 p-4 shadow-xl" className="fixed right-4 bottom-4 z-50 w-80 rounded-lg border border-emerald-400/20 bg-gray-900 p-4 shadow-xl"
> >
<p className="text-sm font-medium text-white">What's new in Phokus v{whatsNewToast}</p> <p className="text-sm font-medium text-white">What's new in Phokus v{whatsNewToast}</p>
<p className="mt-1 text-xs text-gray-500">See what's changed in this version.</p> <p className="mt-1 text-xs text-gray-500">See what's changed in this version.</p>
@@ -40,5 +40,5 @@ export function WhatsNewToast() {
</motion.div> </motion.div>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
); )
} }
@@ -1,66 +1,67 @@
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { CloseIcon } from "../icons"; import { CloseIcon } from '../icons'
import type { BackgroundTask } from "./types"; import type { BackgroundTask } from './types'
export function FailureActions({ export function FailureActions({
onLocate, onLocate,
onRetry, onRetry,
task, task,
}: { }: {
onLocate: (folderId: number) => void; onLocate: (folderId: number) => void
onRetry: (task: BackgroundTask) => void; onRetry: (task: BackgroundTask) => void
task: BackgroundTask; task: BackgroundTask
}) { }) {
if (task.pendingMediaWork !== 0 || (!task.hasFailedEmbeddings && !task.hasFailedTagging)) return null; if (task.pendingMediaWork !== 0 || (!task.hasFailedEmbeddings && !task.hasFailedTagging))
return null
return ( return (
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{task.hasFailedTagging ? ( {task.hasFailedTagging ? (
<button <button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200" className="light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200 rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onLocate(task.id); onLocate(task.id)
}} }}
> >
Locate Locate
</button> </button>
) : null} ) : null}
<button <button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200" className="light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200 rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onRetry(task); onRetry(task)
}} }}
> >
Retry Retry
</button> </button>
</div> </div>
); )
} }
export function DismissTaskButton({ export function DismissTaskButton({
onDismiss, onDismiss,
size = "normal", size = 'normal',
task, task,
}: { }: {
onDismiss: (task: BackgroundTask) => void; onDismiss: (task: BackgroundTask) => void
size?: "normal" | "small"; size?: 'normal' | 'small'
task: BackgroundTask; task: BackgroundTask
}) { }) {
if (task.id < 0) return null; if (task.id < 0) return null
return ( return (
<Tooltip label="Dismiss" anchorToCursor> <Tooltip label="Dismiss" anchorToCursor>
<button <button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0" className="shrink-0 rounded-md p-1 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-300"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onDismiss(task); onDismiss(task)
}} }}
> >
<CloseIcon className={size === "small" ? "h-3 w-3" : "h-3.5 w-3.5"} /> <CloseIcon className={size === 'small' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
</button> </button>
</Tooltip> </Tooltip>
); )
} }
@@ -1,9 +1,9 @@
import type { WorkerKey } from "../../store"; import type { WorkerKey } from '../../store'
import { ChevronDownIcon } from "../icons"; import { ChevronDownIcon } from '../icons'
import { DismissTaskButton, FailureActions } from "./BackgroundTaskActions"; import { DismissTaskButton, FailureActions } from './BackgroundTaskActions'
import { TaskProgressBar } from "./TaskProgressBar"; import { TaskProgressBar } from './TaskProgressBar'
import { TaskStagePill } from "./TaskStagePill"; import { TaskStagePill } from './TaskStagePill'
import type { BackgroundTask } from "./types"; import type { BackgroundTask } from './types'
export function BackgroundTaskSummary({ export function BackgroundTaskSummary({
expanded, expanded,
@@ -19,34 +19,36 @@ export function BackgroundTaskSummary({
progress, progress,
taskCount, taskCount,
}: { }: {
expanded: boolean; expanded: boolean
extraCount: number; extraCount: number
hasFailed: boolean; hasFailed: boolean
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean; isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean
onDismiss: (task: BackgroundTask) => void; onDismiss: (task: BackgroundTask) => void
onLocate: (folderId: number) => void; onLocate: (folderId: number) => void
onRetry: (task: BackgroundTask) => void; onRetry: (task: BackgroundTask) => void
onToggleExpanded: () => void; onToggleExpanded: () => void
onToggleWorker: (folderId: number, worker: WorkerKey) => void; onToggleWorker: (folderId: number, worker: WorkerKey) => void
primary: BackgroundTask; primary: BackgroundTask
progress: number | null; progress: number | null
taskCount: number; taskCount: number
}) { }) {
return ( return (
<div <div
className={`group flex items-center gap-3 px-5 h-11 cursor-pointer select-none transition-colors ${ className={`group flex h-11 cursor-pointer items-center gap-3 px-5 transition-colors select-none ${
expanded ? "bg-white/[0.03]" : "hover:bg-white/[0.02]" expanded ? 'bg-white/[0.03]' : 'hover:bg-white/[0.02]'
}`} }`}
onClick={onToggleExpanded} onClick={onToggleExpanded}
> >
<div className="relative shrink-0"> <div className="relative shrink-0">
<div className={`h-1.5 w-1.5 rounded-full ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} /> <div className={`h-1.5 w-1.5 rounded-full ${hasFailed ? 'bg-amber-400' : 'bg-blue-400'}`} />
<div className={`absolute inset-0 h-1.5 w-1.5 rounded-full animate-ping opacity-60 ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} /> <div
className={`absolute inset-0 h-1.5 w-1.5 animate-ping rounded-full opacity-60 ${hasFailed ? 'bg-amber-400' : 'bg-blue-400'}`}
/>
</div> </div>
<span className="text-[13px] font-medium text-white/60 shrink-0">{primary.name}</span> <span className="shrink-0 text-[13px] font-medium text-white/60">{primary.name}</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden"> <div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{primary.stages.map((stage) => ( {primary.stages.map((stage) => (
<TaskStagePill <TaskStagePill
key={stage.label} key={stage.label}
@@ -61,7 +63,7 @@ export function BackgroundTaskSummary({
<TaskProgressBar failed={hasFailed} progress={progress} widthClass="w-24" /> <TaskProgressBar failed={hasFailed} progress={progress} widthClass="w-24" />
{extraCount > 0 ? ( {extraCount > 0 ? (
<span className="rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-gray-500 shrink-0"> <span className="shrink-0 rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-gray-500">
+{extraCount} +{extraCount}
</span> </span>
) : null} ) : null}
@@ -69,10 +71,12 @@ export function BackgroundTaskSummary({
<FailureActions onLocate={onLocate} onRetry={onRetry} task={primary} /> <FailureActions onLocate={onLocate} onRetry={onRetry} task={primary} />
{taskCount > 1 ? ( {taskCount > 1 ? (
<ChevronDownIcon className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`} /> <ChevronDownIcon
className={`h-3.5 w-3.5 shrink-0 text-gray-600 transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
/>
) : null} ) : null}
<DismissTaskButton onDismiss={onDismiss} task={primary} /> <DismissTaskButton onDismiss={onDismiss} task={primary} />
</div> </div>
); )
} }
@@ -1,10 +1,10 @@
import type { WorkerKey } from "../../store"; import type { WorkerKey } from '../../store'
import { DismissTaskButton, FailureActions } from "./BackgroundTaskActions"; import { DismissTaskButton, FailureActions } from './BackgroundTaskActions'
import { FailedWorkerItemRow } from "./FailedWorkerItemRow"; import { FailedWorkerItemRow } from './FailedWorkerItemRow'
import { taskHasTerminalFailure, taskProgress } from "./taskModel"; import { taskHasTerminalFailure, taskProgress } from './taskModel'
import { TaskProgressBar } from "./TaskProgressBar"; import { TaskProgressBar } from './TaskProgressBar'
import { TaskStagePill } from "./TaskStagePill"; import { TaskStagePill } from './TaskStagePill'
import type { BackgroundTask, FailedWorkerItem } from "./types"; import type { BackgroundTask, FailedWorkerItem } from './types'
export function ExpandedTaskPanel({ export function ExpandedTaskPanel({
failedEmbeddingItems, failedEmbeddingItems,
@@ -16,27 +16,27 @@ export function ExpandedTaskPanel({
onToggleWorker, onToggleWorker,
tasks, tasks,
}: { }: {
failedEmbeddingItems: Record<number, FailedWorkerItem[]>; failedEmbeddingItems: Record<number, FailedWorkerItem[]>
failedTaggingItems: Record<number, FailedWorkerItem[]>; failedTaggingItems: Record<number, FailedWorkerItem[]>
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean; isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean
onDismiss: (task: BackgroundTask) => void; onDismiss: (task: BackgroundTask) => void
onLocate: (folderId: number) => void; onLocate: (folderId: number) => void
onRetry: (task: BackgroundTask) => void; onRetry: (task: BackgroundTask) => void
onToggleWorker: (folderId: number, worker: WorkerKey) => void; onToggleWorker: (folderId: number, worker: WorkerKey) => void
tasks: BackgroundTask[]; tasks: BackgroundTask[]
}) { }) {
return ( return (
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3"> <div className="space-y-3 border-t border-white/[0.06] bg-white/[0.02] px-5 py-3">
{tasks.map((task) => { {tasks.map((task) => {
const progress = taskProgress(task); const progress = taskProgress(task)
const failed = taskHasTerminalFailure(task); const failed = taskHasTerminalFailure(task)
return ( return (
<div key={task.id}> <div key={task.id}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-[12px] text-white/50 w-28 truncate shrink-0">{task.name}</span> <span className="w-28 shrink-0 truncate text-[12px] text-white/50">{task.name}</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden"> <div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{task.stages.map((stage) => ( {task.stages.map((stage) => (
<TaskStagePill <TaskStagePill
key={stage.label} key={stage.label}
@@ -57,28 +57,28 @@ export function ExpandedTaskPanel({
</div> </div>
{task.currentFile ? ( {task.currentFile ? (
<p className="text-[10px] text-gray-600 truncate mt-1 pl-[calc(7rem+0.75rem)]"> <p className="mt-1 truncate pl-[calc(7rem+0.75rem)] text-[10px] text-gray-600">
{task.currentFile} {task.currentFile}
</p> </p>
) : null} ) : null}
{failed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 ? ( {failed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 ? (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5"> <div className="mt-2 space-y-0.5 pl-[calc(7rem+0.75rem)]">
{failedEmbeddingItems[task.id].map((item) => ( {failedEmbeddingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} /> <FailedWorkerItemRow key={item.image_id} item={item} />
))} ))}
</div> </div>
) : null} ) : null}
{failed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 ? ( {failed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 ? (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5"> <div className="mt-2 space-y-0.5 pl-[calc(7rem+0.75rem)]">
{failedTaggingItems[task.id].map((item) => ( {failedTaggingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} /> <FailedWorkerItemRow key={item.image_id} item={item} />
))} ))}
</div> </div>
) : null} ) : null}
</div> </div>
); )
})} })}
</div> </div>
); )
} }
@@ -1,28 +1,36 @@
import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { WarningIcon } from "../icons"; import { WarningIcon } from '../icons'
import type { FailedWorkerItem } from "./types"; import type { FailedWorkerItem } from './types'
export function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) { export function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
return ( return (
<div className="flex min-w-0 items-start gap-1.5"> <div className="flex min-w-0 items-start gap-1.5">
<WarningIcon className="mt-px h-2.5 w-2.5 shrink-0 text-amber-500 light-theme:text-amber-700" strokeWidth={2.5} /> <WarningIcon
className="light-theme:text-amber-700 mt-px h-2.5 w-2.5 shrink-0 text-amber-500"
strokeWidth={2.5}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-medium text-amber-400/80 light-theme:text-amber-700">{item.filename}</p> <p className="light-theme:text-amber-700 truncate text-[10px] font-medium text-amber-400/80">
{item.error ? ( {item.filename}
<p className="truncate text-[9px] text-gray-600">{item.error}</p> </p>
) : null} {item.error ? <p className="truncate text-[9px] text-gray-600">{item.error}</p> : null}
</div> </div>
<Tooltip label="Reveal in Explorer" anchorToCursor> <Tooltip label="Reveal in Explorer" anchorToCursor>
<button <button
className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100" className="light-theme:text-gray-600 light-theme:hover:text-gray-100 shrink-0 text-gray-700 transition-colors hover:text-gray-300"
onClick={() => void revealItemInDir(item.path)} onClick={() => void revealItemInDir(item.path)}
> >
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" /> <path
</svg> strokeLinecap="round"
</button> strokeLinejoin="round"
strokeWidth={1.5}
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
/>
</svg>
</button>
</Tooltip> </Tooltip>
</div> </div>
); )
} }
@@ -1,24 +1,24 @@
export function TaskProgressBar({ export function TaskProgressBar({
failed, failed,
progress, progress,
widthClass = "w-20", widthClass = 'w-20',
}: { }: {
failed: boolean; failed: boolean
progress: number | null; progress: number | null
widthClass?: string; widthClass?: string
}) { }) {
return ( return (
<div className={`${widthClass} h-px bg-white/8 rounded-full overflow-hidden shrink-0`}> <div className={`${widthClass} h-px shrink-0 overflow-hidden rounded-full bg-white/8`}>
<div <div
className={`h-full rounded-full transition-all duration-500 ${ className={`h-full rounded-full transition-all duration-500 ${
failed failed
? "bg-amber-400/60" ? 'bg-amber-400/60'
: progress === null : progress === null
? "bg-blue-500/40 animate-pulse w-full" ? 'w-full animate-pulse bg-blue-500/40'
: "bg-blue-500" : 'bg-blue-500'
}`} }`}
style={progress !== null ? { width: `${progress}%` } : undefined} style={progress !== null ? { width: `${progress}%` } : undefined}
/> />
</div> </div>
); )
} }
@@ -1,8 +1,8 @@
import type { WorkerKey } from "../../store"; import type { WorkerKey } from '../../store'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { PlayIcon } from "../icons"; import { PlayIcon } from '../icons'
import type { TaskStage } from "./types"; import type { TaskStage } from './types'
import { WORKER_FOR_STAGE } from "./types"; import { WORKER_FOR_STAGE } from './types'
export function TaskStagePill({ export function TaskStagePill({
folderId, folderId,
@@ -11,25 +11,25 @@ export function TaskStagePill({
onToggleWorker, onToggleWorker,
stage, stage,
}: { }: {
folderId: number; folderId: number
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean; isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean
mutedWhenPaused?: boolean; mutedWhenPaused?: boolean
onToggleWorker: (folderId: number, worker: WorkerKey) => void; onToggleWorker: (folderId: number, worker: WorkerKey) => void
stage: TaskStage; stage: TaskStage
}) { }) {
const workerKey = WORKER_FOR_STAGE[stage.label]; const workerKey = WORKER_FOR_STAGE[stage.label]
const isPaused = workerKey ? isWorkerPaused(folderId, workerKey) : false; const isPaused = workerKey ? isWorkerPaused(folderId, workerKey) : false
return ( return (
<span <span
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ className={`flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 text-[11px] ${
stage.failed stage.failed
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700" ? 'light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700 bg-amber-500/10 text-amber-400'
: isPaused : isPaused
? "bg-white/4 text-gray-600" ? 'bg-white/4 text-gray-600'
: mutedWhenPaused : mutedWhenPaused
? "bg-white/5 text-gray-500" ? 'bg-white/5 text-gray-500'
: "bg-white/5 text-gray-400" : 'bg-white/5 text-gray-400'
}`} }`}
> >
{isPaused && mutedWhenPaused ? ( {isPaused && mutedWhenPaused ? (
@@ -38,28 +38,34 @@ export function TaskStagePill({
</svg> </svg>
) : null} ) : null}
<span>{stage.label}</span> <span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : isPaused && !mutedWhenPaused ? "text-gray-700" : "text-gray-600"}`}> <span
className={`tabular-nums ${stage.failed ? 'light-theme:text-amber-700 text-amber-500' : isPaused && !mutedWhenPaused ? 'text-gray-700' : 'text-gray-600'}`}
>
{stage.detail} {stage.detail}
</span> </span>
{workerKey ? ( {workerKey ? (
<Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor> <Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor>
<button <button
className={mutedWhenPaused ? "ml-0.5 text-gray-600 hover:text-white transition-colors" : "ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"} className={
onClick={(event) => { mutedWhenPaused
event.stopPropagation(); ? 'ml-0.5 text-gray-600 transition-colors hover:text-white'
onToggleWorker(folderId, workerKey); : 'ml-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:text-white'
}} }
> onClick={(event) => {
{isPaused ? ( event.stopPropagation()
<PlayIcon className="h-2.5 w-2.5" /> onToggleWorker(folderId, workerKey)
) : ( }}
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24"> >
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> {isPaused ? (
</svg> <PlayIcon className="h-2.5 w-2.5" />
)} ) : (
</button> <svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
</button>
</Tooltip> </Tooltip>
) : null} ) : null}
</span> </span>
); )
} }
+131 -76
View File
@@ -1,5 +1,11 @@
import type { DuplicateScanProgress, Folder, FolderJobProgress, IndexProgress, WorkerKey } from "../../store"; import type {
import type { BackgroundTask, TaskStage } from "./types"; DuplicateScanProgress,
Folder,
FolderJobProgress,
IndexProgress,
WorkerKey,
} from '../../store'
import type { BackgroundTask, TaskStage } from './types'
export function buildFolderTasks({ export function buildFolderTasks({
dismissed, dismissed,
@@ -8,110 +14,143 @@ export function buildFolderTasks({
mediaJobProgress, mediaJobProgress,
workerPaused, workerPaused,
}: { }: {
dismissed: Record<number, string>; dismissed: Record<number, string>
folders: Folder[]; folders: Folder[]
indexingProgress: Record<number, IndexProgress>; indexingProgress: Record<number, IndexProgress>
mediaJobProgress: Record<number, FolderJobProgress>; mediaJobProgress: Record<number, FolderJobProgress>
workerPaused: Record<number, Record<WorkerKey, boolean>>; workerPaused: Record<number, Record<WorkerKey, boolean>>
}): BackgroundTask[] { }): BackgroundTask[] {
return folders return folders
.map((folder): BackgroundTask | null => { .map((folder): BackgroundTask | null => {
const index = indexingProgress[folder.id]; const index = indexingProgress[folder.id]
const jobs = mediaJobProgress[folder.id]; const jobs = mediaJobProgress[folder.id]
const thumbnailPending = jobs?.thumbnail_pending ?? 0; const thumbnailPending = jobs?.thumbnail_pending ?? 0
const metadataPending = jobs?.metadata_pending ?? 0; const metadataPending = jobs?.metadata_pending ?? 0
const embeddingPending = jobs?.embedding_pending ?? 0; const embeddingPending = jobs?.embedding_pending ?? 0
const embeddingReady = jobs?.embedding_ready ?? 0; const embeddingReady = jobs?.embedding_ready ?? 0
const embeddingFailed = jobs?.embedding_failed ?? 0; const embeddingFailed = jobs?.embedding_failed ?? 0
const taggingPending = jobs?.tagging_pending ?? 0; const taggingPending = jobs?.tagging_pending ?? 0
const taggingReady = jobs?.tagging_ready ?? 0; const taggingReady = jobs?.tagging_ready ?? 0
const taggingFailed = jobs?.tagging_failed ?? 0; const taggingFailed = jobs?.tagging_failed ?? 0
const captionPending = jobs?.caption_pending ?? 0; const captionPending = jobs?.caption_pending ?? 0
const captionReady = jobs?.caption_ready ?? 0; const captionReady = jobs?.caption_ready ?? 0
const captionFailed = jobs?.caption_failed ?? 0; const captionFailed = jobs?.caption_failed ?? 0
const paused = workerPaused[folder.id]; const paused = workerPaused[folder.id]
const isActive = const isActive =
(!!index && !index.done) || (!!index && !index.done) ||
(thumbnailPending > 0 && !(paused?.thumbnail ?? false)) || (thumbnailPending > 0 && !(paused?.thumbnail ?? false)) ||
(metadataPending > 0 && !(paused?.metadata ?? false)) || (metadataPending > 0 && !(paused?.metadata ?? false)) ||
(embeddingPending > 0 && !(paused?.embedding ?? false)) || (embeddingPending > 0 && !(paused?.embedding ?? false)) ||
(taggingPending > 0 && !(paused?.tagging ?? false)) || (taggingPending > 0 && !(paused?.tagging ?? false)) ||
captionPending > 0; captionPending > 0
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending; const pendingMediaWork =
const embeddingProcessed = embeddingReady + embeddingFailed; thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending
const embeddingTotal = embeddingProcessed + embeddingPending; const embeddingProcessed = embeddingReady + embeddingFailed
const taggingProcessed = taggingReady + taggingFailed; const embeddingTotal = embeddingProcessed + embeddingPending
const taggingTotal = taggingProcessed + taggingPending; const taggingProcessed = taggingReady + taggingFailed
const captionProcessed = captionReady + captionFailed; const taggingTotal = taggingProcessed + taggingPending
const captionTotal = captionProcessed + captionPending; const captionProcessed = captionReady + captionFailed
const hasFailedEmbeddings = embeddingFailed > 0; const captionTotal = captionProcessed + captionPending
const hasFailedTagging = taggingFailed > 0; const hasFailedEmbeddings = embeddingFailed > 0
const hasFailedCaptions = captionFailed > 0; const hasFailedTagging = taggingFailed > 0
const hasFailedCaptions = captionFailed > 0
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null; if (
!index &&
pendingMediaWork === 0 &&
!hasFailedEmbeddings &&
!hasFailedTagging &&
!hasFailedCaptions
)
return null
const stages: TaskStage[] = []; const stages: TaskStage[] = []
if (index && !index.done) { if (index && !index.done) {
stages.push({ stages.push({
label: "Scanning", label: 'Scanning',
detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`, detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`,
progress: index.total > 0 ? (index.indexed / index.total) * 100 : 0, progress: index.total > 0 ? (index.indexed / index.total) * 100 : 0,
failed: false, failed: false,
}); })
} }
if (thumbnailPending > 0) { if (thumbnailPending > 0) {
stages.push({ label: "Thumbnails", detail: thumbnailPending.toLocaleString(), progress: null, failed: false }); stages.push({
label: 'Thumbnails',
detail: thumbnailPending.toLocaleString(),
progress: null,
failed: false,
})
} }
if (metadataPending > 0) { if (metadataPending > 0) {
stages.push({ label: "Metadata", detail: metadataPending.toLocaleString(), progress: null, failed: false }); stages.push({
label: 'Metadata',
detail: metadataPending.toLocaleString(),
progress: null,
failed: false,
})
} }
if (embeddingPending > 0) { if (embeddingPending > 0) {
stages.push({ stages.push({
label: "Embeddings", label: 'Embeddings',
detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`, detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`,
progress: embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0, progress: embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0,
failed: false, failed: false,
}); })
} }
if (taggingPending > 0) { if (taggingPending > 0) {
stages.push({ stages.push({
label: "Tags", label: 'Tags',
detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`, detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`,
progress: taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0, progress: taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0,
failed: false, failed: false,
}); })
} }
if (captionPending > 0) { if (captionPending > 0) {
stages.push({ stages.push({
label: "Captions", label: 'Captions',
detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`, detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`,
progress: captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0, progress: captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0,
failed: false, failed: false,
}); })
} }
if (hasFailedEmbeddings && pendingMediaWork === 0) { if (hasFailedEmbeddings && pendingMediaWork === 0) {
stages.push({ label: "Failed", detail: `${embeddingFailed.toLocaleString()} embeddings`, progress: null, failed: true }); stages.push({
label: 'Failed',
detail: `${embeddingFailed.toLocaleString()} embeddings`,
progress: null,
failed: true,
})
} }
if (hasFailedTagging && pendingMediaWork === 0) { if (hasFailedTagging && pendingMediaWork === 0) {
stages.push({ label: "Failed", detail: `${taggingFailed.toLocaleString()} tags`, progress: null, failed: true }); stages.push({
label: 'Failed',
detail: `${taggingFailed.toLocaleString()} tags`,
progress: null,
failed: true,
})
} }
if (hasFailedCaptions && pendingMediaWork === 0) { if (hasFailedCaptions && pendingMediaWork === 0) {
stages.push({ label: "Failed", detail: `${captionFailed.toLocaleString()} captions`, progress: null, failed: true }); stages.push({
label: 'Failed',
detail: `${captionFailed.toLocaleString()} captions`,
progress: null,
failed: true,
})
} }
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`; const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`
return { return {
id: folder.id, id: folder.id,
@@ -124,35 +163,42 @@ export function buildFolderTasks({
pendingMediaWork, pendingMediaWork,
embeddingProcessed, embeddingProcessed,
embeddingTotal, embeddingTotal,
currentFile: index && !index.done ? (index.current_file || null) : null, currentFile: index && !index.done ? index.current_file || null : null,
snapshot, snapshot,
}; }
}) })
.filter((task): task is BackgroundTask => task !== null) .filter((task): task is BackgroundTask => task !== null)
.filter((task) => dismissed[task.id] !== task.snapshot) .filter((task) => dismissed[task.id] !== task.snapshot)
.sort((a, b) => Number(b.isActive) - Number(a.isActive)); .sort((a, b) => Number(b.isActive) - Number(a.isActive))
} }
export function buildDuplicateScanTask(duplicateScanning: boolean, duplicateScanProgress: DuplicateScanProgress | null): BackgroundTask | null { export function buildDuplicateScanTask(
if (!duplicateScanning) return null; duplicateScanning: boolean,
duplicateScanProgress: DuplicateScanProgress | null
): BackgroundTask | null {
if (!duplicateScanning) return null
return { return {
id: -1, id: -1,
name: "Duplicate Scan", name: 'Duplicate Scan',
stages: [{ stages: [
label: duplicateScanProgress?.phase === "checking" {
? "Checking" label:
: duplicateScanProgress?.phase === "confirming" duplicateScanProgress?.phase === 'checking'
? "Confirming" ? 'Checking'
: "Hashing", : duplicateScanProgress?.phase === 'confirming'
detail: duplicateScanProgress ? 'Confirming'
? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` : 'Hashing',
: "Starting…", detail: duplicateScanProgress
progress: duplicateScanProgress && duplicateScanProgress.total > 0 ? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ''}`
? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100 : 'Starting…',
: null, progress:
failed: false, duplicateScanProgress && duplicateScanProgress.total > 0
}], ? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100
: null,
failed: false,
},
],
isActive: true, isActive: true,
hasFailedEmbeddings: false, hasFailedEmbeddings: false,
hasFailedTagging: false, hasFailedTagging: false,
@@ -161,18 +207,27 @@ export function buildDuplicateScanTask(duplicateScanning: boolean, duplicateScan
embeddingProcessed: 0, embeddingProcessed: 0,
embeddingTotal: 0, embeddingTotal: 0,
currentFile: null, currentFile: null,
snapshot: "", snapshot: '',
}; }
} }
export function taskProgress(task: BackgroundTask): number | null { export function taskProgress(task: BackgroundTask): number | null {
const embeddingStage = task.stages.find((stage) => stage.label === "Embeddings"); const embeddingStage = task.stages.find((stage) => stage.label === 'Embeddings')
const taggingStage = task.stages.find((stage) => stage.label === "Tags"); const taggingStage = task.stages.find((stage) => stage.label === 'Tags')
const scanningStage = task.stages.find((stage) => stage.label === "Scanning"); const scanningStage = task.stages.find((stage) => stage.label === 'Scanning')
const duplicateStage = task.id === -1 ? task.stages[0] : null; const duplicateStage = task.id === -1 ? task.stages[0] : null
return embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null; return (
embeddingStage?.progress ??
taggingStage?.progress ??
scanningStage?.progress ??
duplicateStage?.progress ??
null
)
} }
export function taskHasTerminalFailure(task: BackgroundTask): boolean { export function taskHasTerminalFailure(task: BackgroundTask): boolean {
return (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0; return (
(task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) &&
task.pendingMediaWork === 0
)
} }
+26 -26
View File
@@ -1,37 +1,37 @@
import type { WorkerKey } from "../../store"; import type { WorkerKey } from '../../store'
export const WORKER_FOR_STAGE: Record<string, WorkerKey> = { export const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
Thumbnails: "thumbnail", Thumbnails: 'thumbnail',
Metadata: "metadata", Metadata: 'metadata',
Embeddings: "embedding", Embeddings: 'embedding',
Tags: "tagging", Tags: 'tagging',
}; }
export interface TaskStage { export interface TaskStage {
label: string; label: string
detail: string; detail: string
progress: number | null; progress: number | null
failed: boolean; failed: boolean
} }
export interface BackgroundTask { export interface BackgroundTask {
id: number; id: number
name: string; name: string
stages: TaskStage[]; stages: TaskStage[]
isActive: boolean; isActive: boolean
hasFailedEmbeddings: boolean; hasFailedEmbeddings: boolean
hasFailedTagging: boolean; hasFailedTagging: boolean
hasFailedCaptions: boolean; hasFailedCaptions: boolean
pendingMediaWork: number; pendingMediaWork: number
embeddingProcessed: number; embeddingProcessed: number
embeddingTotal: number; embeddingTotal: number
currentFile: string | null; currentFile: string | null
snapshot: string; snapshot: string
} }
export interface FailedWorkerItem { export interface FailedWorkerItem {
image_id: number; image_id: number
filename: string; filename: string
path: string; path: string
error: string | null; error: string | null
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import { AlbumPicker } from "../AlbumPicker"; import { AlbumPicker } from '../AlbumPicker'
interface BulkAlbumPopoverProps { interface BulkAlbumPopoverProps {
onPick: (albumId: number) => Promise<void>; onPick: (albumId: number) => Promise<void>
} }
export function BulkAlbumPopover({ onPick }: BulkAlbumPopoverProps) { export function BulkAlbumPopover({ onPick }: BulkAlbumPopoverProps) {
@@ -12,5 +12,5 @@ export function BulkAlbumPopover({ onPick }: BulkAlbumPopoverProps) {
> >
<AlbumPicker onPick={(albumId) => void onPick(albumId)} /> <AlbumPicker onPick={(albumId) => void onPick(albumId)} />
</div> </div>
); )
} }
+15 -10
View File
@@ -1,13 +1,18 @@
import { WarningIcon } from "../icons"; import { WarningIcon } from '../icons'
interface BulkDeleteConfirmProps { interface BulkDeleteConfirmProps {
deleting: boolean; deleting: boolean
selectedCount: number; selectedCount: number
onCancel: () => void; onCancel: () => void
onDelete: () => Promise<void>; onDelete: () => Promise<void>
} }
export function BulkDeleteConfirm({ deleting, selectedCount, onCancel, onDelete }: BulkDeleteConfirmProps) { export function BulkDeleteConfirm({
deleting,
selectedCount,
onCancel,
onDelete,
}: BulkDeleteConfirmProps) {
return ( return (
<div <div
data-bulk-popover data-bulk-popover
@@ -18,8 +23,8 @@ export function BulkDeleteConfirm({ deleting, selectedCount, onCancel, onDelete
<p className="text-xs font-semibold">Delete from disk</p> <p className="text-xs font-semibold">Delete from disk</p>
</div> </div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400"> <p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. This removes Permanently delete {selectedCount} file{selectedCount === 1 ? '' : 's'} from your computer.
the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone. This removes the actual file{selectedCount === 1 ? '' : 's'} from disk and cannot be undone.
</p> </p>
<div className="flex justify-end gap-1.5"> <div className="flex justify-end gap-1.5">
<button <button
@@ -33,9 +38,9 @@ export function BulkDeleteConfirm({ deleting, selectedCount, onCancel, onDelete
onClick={() => void onDelete()} onClick={() => void onDelete()}
disabled={deleting} disabled={deleting}
> >
{deleting ? "Deleting…" : `Delete ${selectedCount} from disk`} {deleting ? 'Deleting…' : `Delete ${selectedCount} from disk`}
</button> </button>
</div> </div>
</div> </div>
); )
} }
+11 -11
View File
@@ -1,16 +1,16 @@
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { StarIcon } from "../icons"; import { StarIcon } from '../icons'
interface BulkRatingPopoverProps { interface BulkRatingPopoverProps {
onClose: () => void; onClose: () => void
onSetRating: (rating: number) => Promise<void>; onSetRating: (rating: number) => Promise<void>
} }
export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverProps) { export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverProps) {
const setRating = async (rating: number) => { const setRating = async (rating: number) => {
await onSetRating(rating); await onSetRating(rating)
onClose(); onClose()
}; }
return ( return (
<div <div
@@ -18,9 +18,9 @@ export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverPro
className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur" className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
> >
{Array.from({ length: 5 }, (_, index) => { {Array.from({ length: 5 }, (_, index) => {
const rating = index + 1; const rating = index + 1
return ( return (
<Tooltip key={rating} label={`Set ${rating} star${rating === 1 ? "" : "s"}`}> <Tooltip key={rating} label={`Set ${rating} star${rating === 1 ? '' : 's'}`}>
<button <button
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300" className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
onClick={() => void setRating(rating)} onClick={() => void setRating(rating)}
@@ -28,7 +28,7 @@ export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverPro
<StarIcon className="h-4 w-4" /> <StarIcon className="h-4 w-4" />
</button> </button>
</Tooltip> </Tooltip>
); )
})} })}
<button <button
className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white" className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
@@ -37,5 +37,5 @@ export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverPro
Clear Clear
</button> </button>
</div> </div>
); )
} }
+15 -10
View File
@@ -1,10 +1,10 @@
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
interface BulkSelectionSummaryProps { interface BulkSelectionSummaryProps {
loadedCount: number; loadedCount: number
selectedCount: number; selectedCount: number
totalImages: number; totalImages: number
onSelectAll: () => void; onSelectAll: () => void
} }
export function BulkSelectionSummary({ export function BulkSelectionSummary({
@@ -13,18 +13,23 @@ export function BulkSelectionSummary({
totalImages, totalImages,
onSelectAll, onSelectAll,
}: BulkSelectionSummaryProps) { }: BulkSelectionSummaryProps) {
const showSelectAll = loadedCount < totalImages || loadedCount > selectedCount; const showSelectAll = loadedCount < totalImages || loadedCount > selectedCount
return ( return (
<div className="flex items-center gap-2 px-1.5"> <div className="flex items-center gap-2 px-1.5">
<span className="text-xs font-medium text-white">{selectedCount} selected</span> <span className="text-xs font-medium text-white">{selectedCount} selected</span>
{showSelectAll ? ( {showSelectAll ? (
<Tooltip label={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}> <Tooltip
<button className="text-[11px] text-gray-500 transition-colors hover:text-gray-300" onClick={onSelectAll}> label={loadedCount < totalImages ? 'Selects loaded items only' : 'Select all loaded'}
Select all{loadedCount < totalImages ? " loaded" : ""} >
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={onSelectAll}
>
Select all{loadedCount < totalImages ? ' loaded' : ''}
</button> </button>
</Tooltip> </Tooltip>
) : null} ) : null}
</div> </div>
); )
} }
+13 -9
View File
@@ -1,26 +1,26 @@
import { useBulkTagEditor } from "./useBulkTagEditor"; import { useBulkTagEditor } from './useBulkTagEditor'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { CloseIcon } from "../icons"; import { CloseIcon } from '../icons'
// Presentational tag-editing fields shared by the popover and modal surfaces. // Presentational tag-editing fields shared by the popover and modal surfaces.
export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) { export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
const { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag } = const { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag } =
useBulkTagEditor(); useBulkTagEditor()
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<form <form
className="flex gap-1.5" className="flex gap-1.5"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault()
void addTag(input); void addTag(input)
}} }}
> >
{/* eslint-disable-next-line jsx-a11y/no-autofocus */} {/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input <input
autoFocus={autoFocus} autoFocus={autoFocus}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none" className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder={`Add tag to ${selectedCount} item${selectedCount === 1 ? "" : "s"}`} placeholder={`Add tag to ${selectedCount} item${selectedCount === 1 ? '' : 's'}`}
value={input} value={input}
onChange={(event) => setInput(event.target.value)} onChange={(event) => setInput(event.target.value)}
disabled={pending} disabled={pending}
@@ -37,7 +37,11 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
{suggestions.length > 0 ? ( {suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => ( {suggestions.map((suggestion) => (
<Tooltip key={suggestion.tag} label={`${suggestion.count.toLocaleString()} tagged`} anchorToCursor> <Tooltip
key={suggestion.tag}
label={`${suggestion.count.toLocaleString()} tagged`}
anchorToCursor
>
<button <button
className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white" className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => void addTag(suggestion.tag)} onClick={() => void addTag(suggestion.tag)}
@@ -70,5 +74,5 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
</div> </div>
) : null} ) : null}
</div> </div>
); )
} }
+6 -9
View File
@@ -1,6 +1,6 @@
import { BulkTagFields } from "./BulkTagFields"; import { BulkTagFields } from './BulkTagFields'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { CloseIcon } from "../icons"; import { CloseIcon } from '../icons'
// Inline popover surface for bulk tagging — the default editing surface. // Inline popover surface for bulk tagging — the default editing surface.
// Anchored above the bar by the parent; closes on outside click via the // Anchored above the bar by the parent; closes on outside click via the
@@ -13,17 +13,14 @@ export function BulkTagPopover({ onClose }: { onClose: () => void }) {
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-[11px] uppercase tracking-wider text-gray-500">Add tags</p> <p className="text-[11px] tracking-wider text-gray-500 uppercase">Add tags</p>
<Tooltip label="Close" anchorToCursor> <Tooltip label="Close" anchorToCursor>
<button <button className="text-gray-600 transition-colors hover:text-white" onClick={onClose}>
className="text-gray-600 transition-colors hover:text-white"
onClick={onClose}
>
<CloseIcon className="h-3.5 w-3.5" /> <CloseIcon className="h-3.5 w-3.5" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<BulkTagFields autoFocus /> <BulkTagFields autoFocus />
</div> </div>
); )
} }
+1 -1
View File
@@ -1 +1 @@
export type BulkPanel = "tag" | "rating" | "album" | "delete" | null; export type BulkPanel = 'tag' | 'rating' | 'album' | 'delete' | null
+45 -45
View File
@@ -1,73 +1,73 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from "@tauri-apps/api/core"; import { invoke } from '@tauri-apps/api/core'
import { ExploreTagEntry, useGalleryStore } from "../../store"; import { ExploreTagEntry, useGalleryStore } from '../../store'
// Shared logic for the bulk tag editor, consumed by both the inline popover and // Shared logic for the bulk tag editor, consumed by both the inline popover and
// the modal surface so they stay behaviorally identical. // the modal surface so they stay behaviorally identical.
export function useBulkTagEditor() { export function useBulkTagEditor() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size); const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const bulkAddTags = useGalleryStore((state) => state.bulkAddTags); const bulkAddTags = useGalleryStore((state) => state.bulkAddTags)
const bulkRemoveTag = useGalleryStore((state) => state.bulkRemoveTag); const bulkRemoveTag = useGalleryStore((state) => state.bulkRemoveTag)
const [input, setInput] = useState(""); const [input, setInput] = useState('')
const [suggestions, setSuggestions] = useState<ExploreTagEntry[]>([]); const [suggestions, setSuggestions] = useState<ExploreTagEntry[]>([])
const [appliedTags, setAppliedTags] = useState<string[]>([]); const [appliedTags, setAppliedTags] = useState<string[]>([])
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const requestRef = useRef(0); const requestRef = useRef(0)
useEffect(() => { useEffect(() => {
const query = input.trim(); const query = input.trim()
if (!query) { if (!query) {
requestRef.current += 1; requestRef.current += 1
setSuggestions([]); setSuggestions([])
return; return
} }
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current)
const requestId = ++requestRef.current; const requestId = ++requestRef.current
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
try { try {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", { const results = await invoke<ExploreTagEntry[]>('search_tags_autocomplete', {
params: { query, folder_id: selectedFolderId ?? null, limit: 8 }, params: { query, folder_id: selectedFolderId ?? null, limit: 8 },
}); })
if (requestId !== requestRef.current) return; if (requestId !== requestRef.current) return
setSuggestions(results); setSuggestions(results)
} catch { } catch {
if (requestId !== requestRef.current) return; if (requestId !== requestRef.current) return
setSuggestions([]); setSuggestions([])
} }
}, 120); }, 120)
return () => { return () => {
requestRef.current += 1; requestRef.current += 1
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current)
}; }
}, [input, selectedFolderId]); }, [input, selectedFolderId])
const addTag = useCallback( const addTag = useCallback(
async (raw: string) => { async (raw: string) => {
const tag = raw.trim(); const tag = raw.trim()
if (!tag || pending) return; if (!tag || pending) return
setPending(true); setPending(true)
try { try {
await bulkAddTags([tag]); await bulkAddTags([tag])
setAppliedTags((prev) => (prev.includes(tag) ? prev : [...prev, tag])); setAppliedTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]))
setInput(""); setInput('')
setSuggestions([]); setSuggestions([])
} finally { } finally {
setPending(false); setPending(false)
} }
}, },
[bulkAddTags, pending], [bulkAddTags, pending]
); )
const removeTag = useCallback( const removeTag = useCallback(
async (tag: string) => { async (tag: string) => {
await bulkRemoveTag(tag); await bulkRemoveTag(tag)
setAppliedTags((prev) => prev.filter((entry) => entry !== tag)); setAppliedTags((prev) => prev.filter((entry) => entry !== tag))
}, },
[bulkRemoveTag], [bulkRemoveTag]
); )
return { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag }; return { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag }
} }
@@ -2,29 +2,38 @@ export function DuplicateScanLoadingState({ progressLabel }: { progressLabel: st
return ( return (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25"> <div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" /> <div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
<span className="text-sm">{progressLabel ? `${progressLabel}` : "Preparing scan…"}</span> <span className="text-sm">{progressLabel ? `${progressLabel}` : 'Preparing scan…'}</span>
</div> </div>
); )
} }
export function DuplicateScanIntroState() { export function DuplicateScanIntroState() {
return ( return (
<div className="flex flex-1 items-center justify-center px-8"> <div className="flex flex-1 items-center justify-center px-8">
<div className="max-w-sm text-center"> <div className="max-w-sm text-center">
<svg className="mx-auto mb-4 h-10 w-10 text-white/10" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} className="mx-auto mb-4 h-10 w-10 text-white/10"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg> </svg>
<p className="text-sm text-white/30"> <p className="text-sm text-white/30">
Finds files with identical content regardless of filename or location. Finds files with identical content regardless of filename or location. Click{' '}
Click <strong className="text-white/50">Scan for duplicates</strong> to begin. <strong className="text-white/50">Scan for duplicates</strong> to begin.
</p> </p>
<p className="mt-2 text-xs text-white/20"> <p className="mt-2 text-xs text-white/20">
Large libraries may take a minute files are hashed from disk. Large libraries may take a minute files are hashed from disk.
</p> </p>
</div> </div>
</div> </div>
); )
} }
export function DuplicateScanEmptyState() { export function DuplicateScanEmptyState() {
@@ -32,5 +41,5 @@ export function DuplicateScanEmptyState() {
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<p className="text-sm text-white/25">No duplicate files found.</p> <p className="text-sm text-white/25">No duplicate files found.</p>
</div> </div>
); )
} }
@@ -1,13 +1,13 @@
import { DuplicateGroup, DuplicateScanProgress } from "../../store"; import { DuplicateGroup, DuplicateScanProgress } from '../../store'
import { FolderScopeDropdown } from "../FolderScopeDropdown"; import { FolderScopeDropdown } from '../FolderScopeDropdown'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { WarningIcon } from "../icons"; import { WarningIcon } from '../icons'
import { import {
duplicateProgressLabel, duplicateProgressLabel,
duplicateProgressPercent, duplicateProgressPercent,
formatBytes, formatBytes,
formatRelativeTime, formatRelativeTime,
} from "./format"; } from './format'
export function DuplicateFinderHeader({ export function DuplicateFinderHeader({
confirmingDelete, confirmingDelete,
@@ -29,32 +29,35 @@ export function DuplicateFinderHeader({
selectedCount, selectedCount,
setConfirmingDelete, setConfirmingDelete,
}: { }: {
confirmingDelete: boolean; confirmingDelete: boolean
deleteResult: string | null; deleteResult: string | null
deleting: boolean; deleting: boolean
duplicateGroups: DuplicateGroup[]; duplicateGroups: DuplicateGroup[]
duplicateLastScanned: number | null; duplicateLastScanned: number | null
duplicateScanError: string | null; duplicateScanError: string | null
duplicateScanning: boolean; duplicateScanning: boolean
duplicateScanProgress: DuplicateScanProgress | null; duplicateScanProgress: DuplicateScanProgress | null
duplicateScanWarning: string | null; duplicateScanWarning: string | null
hasResults: boolean; hasResults: boolean
hasScanned: boolean; hasScanned: boolean
onClearSelection: () => void; onClearSelection: () => void
onConfirmDelete: () => void; onConfirmDelete: () => void
onDelete: () => void; onDelete: () => void
onScan: () => void; onScan: () => void
onSelectKeepFirstAll: () => void; onSelectKeepFirstAll: () => void
selectedCount: number; selectedCount: number
setConfirmingDelete: (value: boolean | ((current: boolean) => boolean)) => void; setConfirmingDelete: (value: boolean | ((current: boolean) => boolean)) => void
}) { }) {
const totalWasted = duplicateGroups.reduce( const totalWasted = duplicateGroups.reduce(
(sum, group) => sum + group.file_size * (group.images.length - 1), (sum, group) => sum + group.file_size * (group.images.length - 1),
0, 0
); )
const totalDuplicateImages = duplicateGroups.reduce((sum, group) => sum + group.images.length - 1, 0); const totalDuplicateImages = duplicateGroups.reduce(
const progressLabel = duplicateProgressLabel(duplicateScanProgress); (sum, group) => sum + group.images.length - 1,
const progressPercent = duplicateProgressPercent(duplicateScanProgress); 0
)
const progressLabel = duplicateProgressLabel(duplicateScanProgress)
const progressPercent = duplicateProgressPercent(duplicateScanProgress)
return ( return (
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4"> <div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
@@ -64,13 +67,13 @@ export function DuplicateFinderHeader({
<p className="mt-0.5 text-[11px] text-white/30"> <p className="mt-0.5 text-[11px] text-white/30">
{duplicateScanning {duplicateScanning
? duplicateScanProgress ? duplicateScanProgress
? `${progressLabel}${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` ? `${progressLabel}${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ''}`
: "Starting scan…" : 'Starting scan…'
: hasResults : hasResults
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable` ? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? '' : 's'} · ${formatBytes(totalWasted)} reclaimable`
: duplicateLastScanned !== null : duplicateLastScanned !== null
? "No duplicates found" ? 'No duplicates found'
: "Scan your library for identical files"} : 'Scan your library for identical files'}
</p> </p>
{!duplicateScanning && duplicateLastScanned !== null ? ( {!duplicateScanning && duplicateLastScanned !== null ? (
<p className="mt-0.5 text-[10px] text-white/20"> <p className="mt-0.5 text-[10px] text-white/20">
@@ -81,9 +84,12 @@ export function DuplicateFinderHeader({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FolderScopeDropdown /> <FolderScopeDropdown />
{hasResults && selectedCount === 0 && !deleting ? ( {hasResults && selectedCount === 0 && !deleting ? (
<Tooltip label={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`} anchorToCursor> <Tooltip
label={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? '' : 's'} for deletion across all groups (keeps first in each)`}
anchorToCursor
>
<button <button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white"
onClick={onSelectKeepFirstAll} onClick={onSelectKeepFirstAll}
> >
Select all duplicates Select all duplicates
@@ -94,7 +100,7 @@ export function DuplicateFinderHeader({
<> <>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span> <span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<button <button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40"
onClick={onClearSelection} onClick={onClearSelection}
disabled={deleting} disabled={deleting}
> >
@@ -106,19 +112,22 @@ export function DuplicateFinderHeader({
onClick={() => setConfirmingDelete((value) => !value)} onClick={() => setConfirmingDelete((value) => !value)}
disabled={deleting} disabled={deleting}
> >
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`} {deleting
? 'Deleting…'
: `Delete ${selectedCount} file${selectedCount === 1 ? '' : 's'}`}
</button> </button>
{confirmingDelete && !deleting ? ( {confirmingDelete && !deleting ? (
<> <>
<div className="fixed inset-0 z-40" onClick={onConfirmDelete} /> <div className="fixed inset-0 z-40" onClick={onConfirmDelete} />
<div className="absolute right-0 top-full z-50 mt-2 w-72 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 text-left shadow-2xl backdrop-blur"> <div className="absolute top-full right-0 z-50 mt-2 w-72 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 text-left shadow-2xl backdrop-blur">
<div className="mb-1 flex items-center gap-1.5 text-red-300"> <div className="mb-1 flex items-center gap-1.5 text-red-300">
<WarningIcon className="h-3.5 w-3.5 shrink-0" /> <WarningIcon className="h-3.5 w-3.5 shrink-0" />
<p className="text-xs font-semibold">Delete from disk</p> <p className="text-xs font-semibold">Delete from disk</p>
</div> </div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400"> <p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. Permanently delete {selectedCount} file{selectedCount === 1 ? '' : 's'} from
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone. your computer. This removes the actual file{selectedCount === 1 ? '' : 's'}{' '}
from disk and cannot be undone.
</p> </p>
<div className="flex justify-end gap-1.5"> <div className="flex justify-end gap-1.5">
<button <button
@@ -141,11 +150,11 @@ export function DuplicateFinderHeader({
</> </>
) : null} ) : null}
<button <button
className="rounded-lg 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-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white rounded-lg 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-40"
onClick={onScan} onClick={onScan}
disabled={duplicateScanning} disabled={duplicateScanning}
> >
{duplicateScanning ? "Scanning…" : hasScanned ? "Rescan" : "Scan for duplicates"} {duplicateScanning ? 'Scanning…' : hasScanned ? 'Rescan' : 'Scan for duplicates'}
</button> </button>
</div> </div>
</div> </div>
@@ -165,9 +174,7 @@ export function DuplicateFinderHeader({
{duplicateScanWarning ? ( {duplicateScanWarning ? (
<p className="mt-2 text-[11px] text-amber-300/70">{duplicateScanWarning}</p> <p className="mt-2 text-[11px] text-amber-300/70">{duplicateScanWarning}</p>
) : null} ) : null}
{deleteResult ? ( {deleteResult ? <p className="mt-2 text-[11px] text-white/40">{deleteResult}</p> : null}
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
) : null}
</div> </div>
); )
} }
@@ -1,28 +1,28 @@
import { DuplicateGroup, useGalleryStore } from "../../store"; import { DuplicateGroup, useGalleryStore } from '../../store'
import { mediaSrc } from "../../lib/mediaSrc"; import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { formatBytes } from "./format"; import { formatBytes } from './format'
export function DuplicateGroupCard({ group }: { group: DuplicateGroup }) { export function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds); const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds)
const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected); const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected)
const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates); const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates)
const groupSelectedCount = group.images.filter((image) => selectedIds.has(image.id)).length; const groupSelectedCount = group.images.filter((image) => selectedIds.has(image.id)).length
const noneSelected = groupSelectedCount === 0; const noneSelected = groupSelectedCount === 0
const handleKeepFirst = () => { const handleKeepFirst = () => {
const toDelete = group.images.slice(1).map((image) => image.id); const toDelete = group.images.slice(1).map((image) => image.id)
for (const image of group.images) { for (const image of group.images) {
if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id); if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id)
} }
selectAllDuplicates(toDelete); selectAllDuplicates(toDelete)
}; }
const handleDeselectGroup = () => { const handleDeselectGroup = () => {
for (const image of group.images) { for (const image of group.images) {
if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id); if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id)
} }
}; }
return ( return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-4"> <div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-4">
@@ -57,15 +57,15 @@ export function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{group.images.map((image) => { {group.images.map((image) => {
const isSelected = selectedIds.has(image.id); const isSelected = selectedIds.has(image.id)
const src = mediaSrc(image.thumbnail_path); const src = mediaSrc(image.thumbnail_path)
return ( return (
<Tooltip key={image.id} label={image.path} anchorToCursor> <Tooltip key={image.id} label={image.path} anchorToCursor>
<button <button
className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${ className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${
isSelected isSelected
? "border-red-400/50 ring-1 ring-red-400/30" ? 'border-red-400/50 ring-1 ring-red-400/30'
: "border-white/8 hover:border-white/20" : 'border-white/8 hover:border-white/20'
}`} }`}
style={{ width: 140, height: 105 }} style={{ width: 140, height: 105 }}
onClick={() => toggleDuplicateSelected(image.id)} onClick={() => toggleDuplicateSelected(image.id)}
@@ -77,19 +77,31 @@ export function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
)} )}
{isSelected ? ( {isSelected ? (
<div className="absolute inset-0 flex items-center justify-center bg-red-950/60"> <div className="absolute inset-0 flex items-center justify-center bg-red-950/60">
<svg className="h-6 w-6 text-red-300" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> className="h-6 w-6 text-red-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg> </svg>
</div> </div>
) : null} ) : null}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-1.5 pt-4 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pt-4 pb-1.5 opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p> <p className="truncate text-[9px] text-white/60">
{image.path.split(/[\\/]/).slice(-2).join('/')}
</p>
</div> </div>
</button> </button>
</Tooltip> </Tooltip>
); )
})} })}
</div> </div>
</div> </div>
); )
} }
+15 -15
View File
@@ -1,29 +1,29 @@
import { DuplicateScanProgress } from "../../store"; import { DuplicateScanProgress } from '../../store'
export function formatBytes(bytes: number): string { export 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`
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${bytes} B`; return `${bytes} B`
} }
export function formatRelativeTime(unixSecs: number): string { export function formatRelativeTime(unixSecs: number): string {
const diff = Math.floor(Date.now() / 1000) - unixSecs; const diff = Math.floor(Date.now() / 1000) - unixSecs
if (diff < 60) return "just now"; if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`; return `${Math.floor(diff / 86400)}d ago`
} }
export function duplicateProgressLabel(progress: DuplicateScanProgress | null): string | null { export function duplicateProgressLabel(progress: DuplicateScanProgress | null): string | null {
if (!progress) return null; if (!progress) return null
if (progress.phase === "checking") return "Checking file sizes"; if (progress.phase === 'checking') return 'Checking file sizes'
if (progress.phase === "hashing") return "Hashing duplicate candidates"; if (progress.phase === 'hashing') return 'Hashing duplicate candidates'
return "Confirming exact matches"; return 'Confirming exact matches'
} }
export function duplicateProgressPercent(progress: DuplicateScanProgress | null): number { export function duplicateProgressPercent(progress: DuplicateScanProgress | null): number {
return progress && progress.total > 0 return progress && progress.total > 0
? Math.round((progress.processed / progress.total) * 100) ? Math.round((progress.processed / progress.total) * 100)
: 0; : 0
} }
+189 -146
View File
@@ -1,61 +1,65 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from 'framer-motion'
import type { VisualClusterEntry } from "../../store"; import type { VisualClusterEntry } from '../../store'
import { mediaSrc } from "../../lib/mediaSrc"; import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { ACCENTS, GOLDEN_ANGLE, seeded } from "./layout"; import { ACCENTS, GOLDEN_ANGLE, seeded } from './layout'
interface PlacedNode { interface PlacedNode {
entry: VisualClusterEntry; entry: VisualClusterEntry
index: number; index: number
x: number; x: number
y: number; y: number
w: number; w: number
h: number; h: number
zIndex: number; zIndex: number
accent: string; accent: string
driftX: number; driftX: number
driftY: number; driftY: number
driftDuration: number; driftDuration: number
rotateSeed: number; rotateSeed: number
} }
function buildCloud(entries: VisualClusterEntry[], containerW: number, containerH: number): PlacedNode[] { function buildCloud(
if (!entries.length || containerW <= 0 || containerH <= 0) return []; entries: VisualClusterEntry[],
containerW: number,
containerH: number
): PlacedNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return []
const maxCount = Math.max(...entries.map((e) => e.count)); const maxCount = Math.max(...entries.map((e) => e.count))
const cx = containerW / 2; const cx = containerW / 2
const cy = containerH / 2; const cy = containerH / 2
const n = entries.length; const n = entries.length
const ASPECT = 0.72; const ASPECT = 0.72
const PAD = 18; const PAD = 18
// Card width scales with image count; the sub-linear exponent (< 1) widens the // Card width scales with image count; the sub-linear exponent (< 1) widens the
// gap so the busiest clusters read as clearly larger and more prominent. // gap so the busiest clusters read as clearly larger and more prominent.
const rawWidth = (count: number) => { const rawWidth = (count: number) => {
const ratio = Math.max(count / maxCount, 0.05); const ratio = Math.max(count / maxCount, 0.05)
return 92 + Math.pow(ratio, 0.65) * 158; // ~92250px before fit scaling return 92 + Math.pow(ratio, 0.65) * 158 // ~92250px before fit scaling
}; }
// Shrink every card uniformly when their padded footprint can't fit the // Shrink every card uniformly when their padded footprint can't fit the
// canvas, so overlap resolution can actually pull them apart instead of // canvas, so overlap resolution can actually pull them apart instead of
// settling into a pile. (0.6 leaves headroom for imperfect packing.) // settling into a pile. (0.6 leaves headroom for imperfect packing.)
const totalArea = entries.reduce((sum, e) => { const totalArea = entries.reduce((sum, e) => {
const w = rawWidth(e.count); const w = rawWidth(e.count)
return sum + (w + PAD) * (w * ASPECT + PAD); return sum + (w + PAD) * (w * ASPECT + PAD)
}, 0); }, 0)
const usableArea = containerW * containerH * 0.6; const usableArea = containerW * containerH * 0.6
const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1; const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1
const spreadX = containerW * 0.44; const spreadX = containerW * 0.44
const spreadY = containerH * 0.4; const spreadY = containerH * 0.4
// 1. Seed positions on a phyllotaxis spiral, sized by count. // 1. Seed positions on a phyllotaxis spiral, sized by count.
const nodes: PlacedNode[] = entries.map((entry, i) => { const nodes: PlacedNode[] = entries.map((entry, i) => {
const w = rawWidth(entry.count) * fit; const w = rawWidth(entry.count) * fit
const h = w * ASPECT; const h = w * ASPECT
const radialRatio = Math.sqrt((i + 0.5) / n); const radialRatio = Math.sqrt((i + 0.5) / n)
const angle = i * GOLDEN_ANGLE; const angle = i * GOLDEN_ANGLE
return { return {
entry, entry,
@@ -72,122 +76,161 @@ function buildCloud(entries: VisualClusterEntry[], containerW: number, container
driftY: (seeded(i + 17) - 0.5) * 14, driftY: (seeded(i + 17) - 0.5) * 14,
driftDuration: 8 + seeded(i + 23) * 7, driftDuration: 8 + seeded(i + 23) * 7,
rotateSeed: (seeded(i + 31) - 0.5) * 4, rotateSeed: (seeded(i + 31) - 0.5) * 4,
}; }
}); })
// 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every // 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every
// pass so edge cards settle in-bounds instead of being shoved out and // pass so edge cards settle in-bounds instead of being shoved out and
// re-overlapping there. // re-overlapping there.
const marginX = 14; const marginX = 14
const marginY = 14; const marginY = 14
for (let iter = 0; iter < 160; iter++) { for (let iter = 0; iter < 160; iter++) {
for (let a = 0; a < nodes.length; a++) { for (let a = 0; a < nodes.length; a++) {
const na = nodes[a]; const na = nodes[a]
for (let b = a + 1; b < nodes.length; b++) { for (let b = a + 1; b < nodes.length; b++) {
const nb = nodes[b]; const nb = nodes[b]
const dx = nb.x - na.x; const dx = nb.x - na.x
const dy = nb.y - na.y; const dy = nb.y - na.y
const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx); const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx)
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy); const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy)
if (overlapX <= 0 || overlapY <= 0) continue; if (overlapX <= 0 || overlapY <= 0) continue
// Push along the smaller overlap axis (ternary yields ±1 so coincident // Push along the smaller overlap axis (ternary yields ±1 so coincident
// cards still separate rather than stalling at a zero push). // cards still separate rather than stalling at a zero push).
if (overlapX < overlapY) { if (overlapX < overlapY) {
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); const push = (overlapX / 2) * (dx >= 0 ? 1 : -1)
nb.x += push; nb.x += push
na.x -= push; na.x -= push
} else { } else {
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); const push = (overlapY / 2) * (dy >= 0 ? 1 : -1)
nb.y += push; nb.y += push
na.y -= push; na.y -= push
} }
} }
} }
for (const node of nodes) { for (const node of nodes) {
node.x = Math.min(Math.max(node.x, node.w / 2 + marginX), containerW - node.w / 2 - marginX); node.x = Math.min(Math.max(node.x, node.w / 2 + marginX), containerW - node.w / 2 - marginX)
node.y = Math.min(Math.max(node.y, node.h / 2 + marginY), containerH - node.h / 2 - marginY); node.y = Math.min(Math.max(node.y, node.h / 2 + marginY), containerH - node.h / 2 - marginY)
} }
} }
return nodes; return nodes
} }
function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) { function CloudCard({
const src = mediaSrc(node.entry.thumbnail_path); node,
const { w, h, accent } = node; onOpen,
animated,
}: {
node: PlacedNode
onOpen: (imageIds: number[]) => void
animated: boolean
}) {
const src = mediaSrc(node.entry.thumbnail_path)
const { w, h, accent } = node
const driftTransition = { const driftTransition = {
duration: node.driftDuration, duration: node.driftDuration,
ease: "easeInOut" as const, ease: 'easeInOut' as const,
delay: seeded(node.index + 41) * 1.6, delay: seeded(node.index + 41) * 1.6,
repeat: 1, repeat: 1,
repeatType: "reverse" as const, repeatType: 'reverse' as const,
}; }
return ( return (
<Tooltip label={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`} followCursor> <Tooltip
<motion.button label={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? 'image' : 'images'}`}
className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30" followCursor
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2, zIndex: node.zIndex }}
initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }}
animate={
animated
? {
opacity: 1,
scale: 1,
x: [0, node.driftX * 0.65, 0],
y: [0, node.driftY * 0.65, 0],
rotate: [node.rotateSeed, node.rotateSeed + 0.8, node.rotateSeed],
}
: { opacity: 1, scale: 1, rotate: node.rotateSeed }
}
transition={
animated
? {
opacity: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
scale: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
x: driftTransition,
y: { ...driftTransition, duration: node.driftDuration + 1.2, delay: seeded(node.index + 51) * 1.6 },
rotate: { ...driftTransition, duration: node.driftDuration + 0.8, delay: seeded(node.index + 61) * 1.2 },
}
: { opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) }, scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) } }
}
whileHover={{ scale: 1.06, rotate: 0, zIndex: 500, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)}
> >
{src ? ( <motion.button
<img className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
src={src} style={{
alt="" width: w,
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" height: h,
draggable={false} left: node.x - w / 2,
loading="lazy" top: node.y - h / 2,
decoding="async" zIndex: node.zIndex,
/> }}
) : ( initial={
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" /> animated
)} ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed }
<div className="explore-cluster-overlay absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" /> : { opacity: 0, scale: 0.96 }
{/* Accent glow on hover */} }
<div animate={
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" animated
style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }} ? {
/> opacity: 1,
<div className="absolute inset-x-0 bottom-0 p-3"> scale: 1,
<div className="explore-cluster-rule mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} /> x: [0, node.driftX * 0.65, 0],
<p className="explore-cluster-label text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p> y: [0, node.driftY * 0.65, 0],
<p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p> rotate: [node.rotateSeed, node.rotateSeed + 0.8, node.rotateSeed],
</div> }
{/* Anchored to the card corner (not in the count's flex row) so a wide : { opacity: 1, scale: 1, rotate: node.rotateSeed }
count can't push it past the edge on small cards. */} }
<span transition={
className="explore-cluster-open absolute bottom-3 right-3 rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100" animated
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }} ? {
opacity: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
scale: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
x: driftTransition,
y: {
...driftTransition,
duration: node.driftDuration + 1.2,
delay: seeded(node.index + 51) * 1.6,
},
rotate: {
...driftTransition,
duration: node.driftDuration + 0.8,
delay: seeded(node.index + 61) * 1.2,
},
}
: {
opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) },
scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) },
}
}
whileHover={{ scale: 1.06, rotate: 0, zIndex: 500, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)}
> >
Open {src ? (
</span> <img
</motion.button> src={src}
alt=""
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
)}
<div className="explore-cluster-overlay absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
{/* Accent glow on hover */}
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div
className="explore-cluster-rule mb-2 h-px rounded-full"
style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }}
/>
<p className="explore-cluster-label text-[9px] tracking-[0.18em] text-white/35 uppercase">
Cluster
</p>
<p className="explore-cluster-count text-base leading-none font-semibold text-white">
{node.entry.count.toLocaleString()}
</p>
</div>
{/* Anchored to the card corner (not in the count's flex row) so a wide
count can't push it past the edge on small cards. */}
<span
className="explore-cluster-open absolute right-3 bottom-3 rounded-full border px-2 py-0.5 text-[9px] tracking-[0.1em] uppercase opacity-0 transition-opacity duration-200 group-hover:opacity-100"
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
>
Open
</span>
</motion.button>
</Tooltip> </Tooltip>
); )
} }
// Separate component so its useLayoutEffect fires when the canvas is actually // Separate component so its useLayoutEffect fires when the canvas is actually
@@ -197,34 +240,34 @@ export function ClusterCloud({
entries, entries,
onOpen, onOpen,
}: { }: {
entries: VisualClusterEntry[]; entries: VisualClusterEntry[]
onOpen: (imageIds: number[]) => void; onOpen: (imageIds: number[]) => void
}) { }) {
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion()
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null)
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 })
useLayoutEffect(() => { useLayoutEffect(() => {
const el = canvasRef.current; const el = canvasRef.current
if (!el) return; if (!el) return
const update = () => { const update = () => {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect()
setCanvasSize({ w: r.width, h: r.height }); setCanvasSize({ w: r.width, h: r.height })
}; }
update(); update()
const ro = new ResizeObserver(update); const ro = new ResizeObserver(update)
ro.observe(el); ro.observe(el)
return () => ro.disconnect(); return () => ro.disconnect()
}, []); }, [])
const nodes = useMemo( const nodes = useMemo(
() => buildCloud(entries, canvasSize.w, canvasSize.h), () => buildCloud(entries, canvasSize.w, canvasSize.h),
[entries, canvasSize.w, canvasSize.h], [entries, canvasSize.w, canvasSize.h]
); )
return ( return (
<div ref={canvasRef} className="relative isolate min-h-0 flex-1 overflow-hidden"> <div ref={canvasRef} className="relative isolate min-h-0 flex-1 overflow-hidden">
<div className="explore-cluster-grid pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" /> <div className="explore-cluster-grid pointer-events-none absolute inset-0 [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px] opacity-[0.07]" />
{nodes.map((node) => ( {nodes.map((node) => (
<CloudCard <CloudCard
key={`${node.entry.representative_image_id}:${node.index}`} key={`${node.entry.representative_image_id}:${node.index}`}
@@ -234,5 +277,5 @@ export function ClusterCloud({
/> />
))} ))}
</div> </div>
); )
} }
+10 -12
View File
@@ -1,22 +1,22 @@
import { motion } from "framer-motion"; import { motion } from 'framer-motion'
import type { ExploreMode } from "../../store"; import type { ExploreMode } from '../../store'
function Spinner() { function Spinner() {
return ( return (
<motion.div <motion.div
className="explore-spinner h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50" className="explore-spinner h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }} transition={{ duration: 0.85, repeat: Infinity, ease: 'linear' }}
/> />
); )
} }
export function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) { export function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) {
const title = mode === "visual" ? "Building visual clusters" : "Reading tag frequencies"; const title = mode === 'visual' ? 'Building visual clusters' : 'Reading tag frequencies'
const subtitle = const subtitle =
mode === "visual" mode === 'visual'
? "Grouping similar images into browsable clusters." ? 'Grouping similar images into browsable clusters.'
: "Collecting tags from the current library scope."; : 'Collecting tags from the current library scope.'
return ( return (
<div className="flex flex-1 items-center justify-center px-8"> <div className="flex flex-1 items-center justify-center px-8">
@@ -30,12 +30,10 @@ export function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) {
<motion.div <motion.div
className="h-full w-16 rounded-full bg-white/25" className="h-full w-16 rounded-full bg-white/25"
animate={{ x: [-72, 184] }} animate={{ x: [-72, 184] }}
transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut" }} transition={{ duration: 1.4, repeat: Infinity, ease: 'easeInOut' }}
/> />
</div> </div>
</div> </div>
</div> </div>
); )
} }
+195 -171
View File
@@ -1,54 +1,59 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from 'framer-motion'
import type { ExploreTagEntry, RelatedTagEntry } from "../../store"; import type { ExploreTagEntry, RelatedTagEntry } from '../../store'
import { useGalleryStore } from "../../store"; import { useGalleryStore } from '../../store'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { ACCENTS, GOLDEN_ANGLE, LIGHT_ACCENTS, seeded } from "./layout"; import { ACCENTS, GOLDEN_ANGLE, LIGHT_ACCENTS, seeded } from './layout'
interface AtlasNode { interface AtlasNode {
entry: ExploreTagEntry; entry: ExploreTagEntry
index: number; index: number
x: number; x: number
y: number; y: number
w: number; w: number
h: number; h: number
fontSize: number; fontSize: number
ratio: number; ratio: number
accent: string; accent: string
driftX: number; driftX: number
driftY: number; driftY: number
} }
interface TagAnchor { interface TagAnchor {
x: number; x: number
y: number; y: number
} }
export const TAG_ATLAS_MAX_VISIBLE = 132; export const TAG_ATLAS_MAX_VISIBLE = 132
const TAG_ATLAS_DENSE_THRESHOLD = 120; const TAG_ATLAS_DENSE_THRESHOLD = 120
function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, containerH: number, isLight: boolean): AtlasNode[] { function buildTagAtlas(
if (!entries.length || containerW <= 0 || containerH <= 0) return []; entries: ExploreTagEntry[],
containerW: number,
containerH: number,
isLight: boolean
): AtlasNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return []
const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1))); const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1)))
const logMin = Math.min(...logs); const logMin = Math.min(...logs)
const logRange = Math.max(...logs) - logMin || 1; const logRange = Math.max(...logs) - logMin || 1
const cx = containerW / 2; const cx = containerW / 2
const cy = containerH * 0.48; const cy = containerH * 0.48
const spreadX = containerW * 0.47; const spreadX = containerW * 0.47
const spreadY = containerH * 0.46; const spreadY = containerH * 0.46
const accents = isLight ? LIGHT_ACCENTS : ACCENTS; const accents = isLight ? LIGHT_ACCENTS : ACCENTS
const nodes = entries.map((entry, index) => { const nodes = entries.map((entry, index) => {
const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange; const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange
const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1; const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1
const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale; const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale
const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2; const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2
const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26); const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26)
const w = Math.min(containerW * maxWidthRatio, textWidth); const w = Math.min(containerW * maxWidthRatio, textWidth)
const h = fontSize * 1.18 + 14; const h = fontSize * 1.18 + 14
const radialRatio = Math.sqrt((index + 0.5) / entries.length); const radialRatio = Math.sqrt((index + 0.5) / entries.length)
const angle = index * GOLDEN_ANGLE; const angle = index * GOLDEN_ANGLE
return { return {
entry, entry,
index, index,
@@ -61,41 +66,41 @@ function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, container
accent: accents[index % accents.length], accent: accents[index % accents.length],
driftX: (seeded(index + 101) - 0.5) * 7, driftX: (seeded(index + 101) - 0.5) * 7,
driftY: (seeded(index + 113) - 0.5) * 6, driftY: (seeded(index + 113) - 0.5) * 6,
}; }
}); })
const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10; const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10
const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7; const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7
const margin = 28; const margin = 28
for (let iter = 0; iter < 140; iter++) { for (let iter = 0; iter < 140; iter++) {
for (let a = 0; a < nodes.length; a++) { for (let a = 0; a < nodes.length; a++) {
const na = nodes[a]; const na = nodes[a]
for (let b = a + 1; b < nodes.length; b++) { for (let b = a + 1; b < nodes.length; b++) {
const nb = nodes[b]; const nb = nodes[b]
const dx = nb.x - na.x; const dx = nb.x - na.x
const dy = nb.y - na.y; const dy = nb.y - na.y
const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx); const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx)
const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy); const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy)
if (overlapX <= 0 || overlapY <= 0) continue; if (overlapX <= 0 || overlapY <= 0) continue
if (overlapX < overlapY) { if (overlapX < overlapY) {
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); const push = (overlapX / 2) * (dx >= 0 ? 1 : -1)
nb.x += push; nb.x += push
na.x -= push; na.x -= push
} else { } else {
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); const push = (overlapY / 2) * (dy >= 0 ? 1 : -1)
nb.y += push; nb.y += push
na.y -= push; na.y -= push
} }
} }
} }
for (const node of nodes) { for (const node of nodes) {
node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin); node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin)
node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin); node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin)
if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) { if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) {
node.y += (seeded(node.index + iter + 211) - 0.5) * 2; node.y += (seeded(node.index + iter + 211) - 0.5) * 2
} }
if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) { if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) {
node.x += (seeded(node.index + iter + 223) - 0.5) * 2; node.x += (seeded(node.index + iter + 223) - 0.5) * 2
} }
} }
} }
@@ -103,25 +108,31 @@ function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, container
for (let iter = 0; iter < 5; iter++) { for (let iter = 0; iter < 5; iter++) {
const weighted = nodes.reduce( const weighted = nodes.reduce(
(acc, node) => { (acc, node) => {
const weight = 1 + Math.pow(node.ratio, 1.35) * 9; const weight = 1 + Math.pow(node.ratio, 1.35) * 9
return { return {
x: acc.x + node.x * weight, x: acc.x + node.x * weight,
y: acc.y + node.y * weight, y: acc.y + node.y * weight,
weight: acc.weight + weight, weight: acc.weight + weight,
}; }
}, },
{ x: 0, y: 0, weight: 0 }, { x: 0, y: 0, weight: 0 }
); )
const offsetX = containerW * 0.48 - weighted.x / weighted.weight; const offsetX = containerW * 0.48 - weighted.x / weighted.weight
const offsetY = containerH * 0.44 - weighted.y / weighted.weight; const offsetY = containerH * 0.44 - weighted.y / weighted.weight
if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break; if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break
for (const node of nodes) { for (const node of nodes) {
node.x = Math.min(Math.max(node.x + offsetX, node.w / 2 + margin), containerW - node.w / 2 - margin); node.x = Math.min(
node.y = Math.min(Math.max(node.y + offsetY, node.h / 2 + margin), containerH - node.h / 2 - margin); Math.max(node.x + offsetX, node.w / 2 + margin),
containerW - node.w / 2 - margin
)
node.y = Math.min(
Math.max(node.y + offsetY, node.h / 2 + margin),
containerH - node.h / 2 - margin
)
} }
} }
return nodes; return nodes
} }
export function TagAtlas({ export function TagAtlas({
@@ -129,145 +140,155 @@ export function TagAtlas({
onSearch, onSearch,
loadRelatedTags, loadRelatedTags,
}: { }: {
entries: ExploreTagEntry[]; entries: ExploreTagEntry[]
onSearch: (tag: string) => void; onSearch: (tag: string) => void
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>; loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>
}) { }) {
const theme = useGalleryStore((state) => state.theme); const theme = useGalleryStore((state) => state.theme)
const isLight = theme === "subtle-light"; const isLight = theme === 'subtle-light'
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion()
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null)
const buttonRefs = useRef(new Map<string, HTMLButtonElement>()); const buttonRefs = useRef(new Map<string, HTMLButtonElement>())
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 })
const [activeTag, setActiveTag] = useState<string | null>(null); const [activeTag, setActiveTag] = useState<string | null>(null)
const [relatedTags, setRelatedTags] = useState<RelatedTagEntry[]>([]); const [relatedTags, setRelatedTags] = useState<RelatedTagEntry[]>([])
const [anchors, setAnchors] = useState<Record<string, TagAnchor>>({}); const [anchors, setAnchors] = useState<Record<string, TagAnchor>>({})
const visibleEntries = useMemo( const visibleEntries = useMemo(
() => (entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries), () =>
[entries], entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries,
); [entries]
)
useLayoutEffect(() => { useLayoutEffect(() => {
const el = canvasRef.current; const el = canvasRef.current
if (!el) return; if (!el) return
const update = () => { const update = () => {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect()
setCanvasSize({ w: r.width, h: r.height }); setCanvasSize({ w: r.width, h: r.height })
}; }
update(); update()
const ro = new ResizeObserver(update); const ro = new ResizeObserver(update)
ro.observe(el); ro.observe(el)
return () => ro.disconnect(); return () => ro.disconnect()
}, []); }, [])
useEffect(() => { useEffect(() => {
if (!activeTag) { if (!activeTag) {
setRelatedTags([]); setRelatedTags([])
return; return
} }
let cancelled = false; let cancelled = false
void loadRelatedTags(activeTag).then((entries) => { void loadRelatedTags(activeTag).then((entries) => {
if (!cancelled) setRelatedTags(entries); if (!cancelled) setRelatedTags(entries)
}); })
return () => { return () => {
cancelled = true; cancelled = true
}; }
}, [activeTag, loadRelatedTags]); }, [activeTag, loadRelatedTags])
const nodes = useMemo( const nodes = useMemo(
() => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight), () => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight),
[visibleEntries, canvasSize.w, canvasSize.h, isLight], [visibleEntries, canvasSize.w, canvasSize.h, isLight]
); )
const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes]); const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes])
const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined; const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined
const minOpacity = isLight ? 0.62 : 0.42; const minOpacity = isLight ? 0.62 : 0.42
const visibleConnections = useMemo( const visibleConnections = useMemo(
() => () =>
activeNode activeNode
? relatedTags ? relatedTags
.map((related) => { .map((related) => {
const node = nodeByTag.get(related.tag); const node = nodeByTag.get(related.tag)
return node ? { node, related } : null; return node ? { node, related } : null
}) })
.filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null) .filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null)
.slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10) .slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10)
: [], : [],
[activeNode, nodeByTag, relatedTags, visibleEntries.length], [activeNode, nodeByTag, relatedTags, visibleEntries.length]
); )
const activeAnchor = activeTag ? anchors[activeTag] : undefined; const activeAnchor = activeTag ? anchors[activeTag] : undefined
const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count)); const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count))
const connectedByTag = useMemo( const connectedByTag = useMemo(
() => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])), () => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])),
[visibleConnections], [visibleConnections]
); )
const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => { const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => {
if (element) { if (element) {
buttonRefs.current.set(tag, element); buttonRefs.current.set(tag, element)
} else { } else {
buttonRefs.current.delete(tag); buttonRefs.current.delete(tag)
} }
}, []); }, [])
const measureAnchors = useCallback(() => { const measureAnchors = useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current
if (!canvas || !activeTag) { if (!canvas || !activeTag) {
setAnchors({}); setAnchors({})
return; return
} }
const canvasRect = canvas.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect()
const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)]; const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)]
const nextAnchors: Record<string, TagAnchor> = {}; const nextAnchors: Record<string, TagAnchor> = {}
for (const tag of tagsToMeasure) { for (const tag of tagsToMeasure) {
const element = buttonRefs.current.get(tag); const element = buttonRefs.current.get(tag)
if (!element) continue; if (!element) continue
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect()
nextAnchors[tag] = { nextAnchors[tag] = {
x: rect.left - canvasRect.left + rect.width / 2, x: rect.left - canvasRect.left + rect.width / 2,
y: rect.top - canvasRect.top + rect.height / 2, y: rect.top - canvasRect.top + rect.height / 2,
}; }
} }
setAnchors(nextAnchors); setAnchors(nextAnchors)
}, [activeTag, visibleConnections]); }, [activeTag, visibleConnections])
useLayoutEffect(() => { useLayoutEffect(() => {
if (!activeTag) { if (!activeTag) {
setAnchors({}); setAnchors({})
return; return
} }
let firstFrame = 0; let firstFrame = 0
let secondFrame = 0; let secondFrame = 0
const settleTimer = window.setTimeout(measureAnchors, 180); const settleTimer = window.setTimeout(measureAnchors, 180)
firstFrame = window.requestAnimationFrame(() => { firstFrame = window.requestAnimationFrame(() => {
measureAnchors(); measureAnchors()
secondFrame = window.requestAnimationFrame(measureAnchors); secondFrame = window.requestAnimationFrame(measureAnchors)
}); })
return () => { return () => {
window.cancelAnimationFrame(firstFrame); window.cancelAnimationFrame(firstFrame)
window.cancelAnimationFrame(secondFrame); window.cancelAnimationFrame(secondFrame)
window.clearTimeout(settleTimer); window.clearTimeout(settleTimer)
}; }
}, [activeTag, canvasSize.w, canvasSize.h, measureAnchors]); }, [activeTag, canvasSize.w, canvasSize.h, measureAnchors])
return ( return (
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden px-8 py-8"> <div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden px-8 py-8">
<svg className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true"> <svg className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<defs> <defs>
<radialGradient id="tag-atlas-glow" cx="50%" cy="50%" r="50%"> <radialGradient id="tag-atlas-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={isLight ? "rgba(60,50,30,0.18)" : "rgba(255,255,255,0.2)"} /> <stop
offset="0%"
stopColor={isLight ? 'rgba(60,50,30,0.18)' : 'rgba(255,255,255,0.2)'}
/>
<stop offset="100%" stopColor="rgba(0,0,0,0)" /> <stop offset="100%" stopColor="rgba(0,0,0,0)" />
</radialGradient> </radialGradient>
</defs> </defs>
{activeNode && activeAnchor ? ( {activeNode && activeAnchor ? (
<circle cx={activeAnchor.x} cy={activeAnchor.y} r={Math.max(activeNode.w, activeNode.h) * 0.62} fill="url(#tag-atlas-glow)" opacity="0.24" /> <circle
cx={activeAnchor.x}
cy={activeAnchor.y}
r={Math.max(activeNode.w, activeNode.h) * 0.62}
fill="url(#tag-atlas-glow)"
opacity="0.24"
/>
) : null} ) : null}
{visibleConnections.map(({ node, related }) => { {visibleConnections.map(({ node, related }) => {
const from = activeAnchor; const from = activeAnchor
const to = anchors[node.entry.tag]; const to = anchors[node.entry.tag]
if (!from || !to) return null; if (!from || !to) return null
const strength = related.shared_count / maxShared; const strength = related.shared_count / maxShared
return ( return (
<g key={`${activeTag}:${node.entry.tag}`}> <g key={`${activeTag}:${node.entry.tag}`}>
<motion.line <motion.line
@@ -282,7 +303,7 @@ export function TagAtlas({
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
initial={reducedMotion ? undefined : { pathLength: 0, opacity: 0 }} initial={reducedMotion ? undefined : { pathLength: 0, opacity: 0 }}
animate={reducedMotion ? undefined : { pathLength: 1, opacity: 1 }} animate={reducedMotion ? undefined : { pathLength: 1, opacity: 1 }}
transition={{ duration: 0.32, ease: "easeOut" }} transition={{ duration: 0.32, ease: 'easeOut' }}
/> />
{!reducedMotion ? ( {!reducedMotion ? (
<motion.line <motion.line
@@ -302,32 +323,32 @@ export function TagAtlas({
transition={{ transition={{
duration: 4.1 + seeded(node.index + 307) * 1.2, duration: 4.1 + seeded(node.index + 307) * 1.2,
repeat: Infinity, repeat: Infinity,
ease: "easeInOut", ease: 'easeInOut',
delay: seeded(node.index + 317) * 0.75, delay: seeded(node.index + 317) * 0.75,
}} }}
/> />
) : null} ) : null}
</g> </g>
); )
})} })}
</svg> </svg>
{nodes.map((node) => { {nodes.map((node) => {
const isActive = activeTag === node.entry.tag; const isActive = activeTag === node.entry.tag
const connectedRelated = connectedByTag.get(node.entry.tag); const connectedRelated = connectedByTag.get(node.entry.tag)
const isRelated = connectedRelated !== undefined; const isRelated = connectedRelated !== undefined
const dimmed = activeTag !== null && !isActive && !isRelated; const dimmed = activeTag !== null && !isActive && !isRelated
const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity); const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity)
return ( return (
<Tooltip <Tooltip
key={node.entry.tag} key={node.entry.tag}
label={`${node.entry.tag}${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`} label={`${node.entry.tag}${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? 'image' : 'images'}`}
followCursor followCursor
delay={250} delay={250}
> >
<button <button
ref={(element) => setButtonRef(node.entry.tag, element)} ref={(element) => setButtonRef(node.entry.tag, element)}
className={`explore-tag-word group absolute inline-flex items-center justify-center rounded-full px-2 py-1 text-center transition-[opacity,transform] duration-300 ease-out before:absolute before:inset-x-[-14px] before:inset-y-[-8px] before:rounded-full before:bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.16),rgba(255,255,255,0.06)_46%,rgba(255,255,255,0)_72%)] before:opacity-0 before:blur-md before:transition-opacity before:duration-300 before:content-[''] hover:before:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/25 ${ className={`explore-tag-word group absolute inline-flex items-center justify-center rounded-full px-2 py-1 text-center transition-[opacity,transform] duration-300 ease-out before:absolute before:inset-x-[-14px] before:inset-y-[-8px] before:rounded-full before:bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.16),rgba(255,255,255,0.06)_46%,rgba(255,255,255,0)_72%)] before:opacity-0 before:blur-md before:transition-opacity before:duration-300 before:content-[''] hover:before:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/25 ${
isActive ? "before:opacity-100" : "" isActive ? 'before:opacity-100' : ''
}`} }`}
style={{ style={{
left: node.x - node.w / 2, left: node.x - node.w / 2,
@@ -335,7 +356,8 @@ export function TagAtlas({
width: node.w, width: node.w,
minHeight: node.h, minHeight: node.h,
fontSize: node.fontSize, fontSize: node.fontSize,
color: node.ratio > 0.55 ? node.accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)", color:
node.ratio > 0.55 ? node.accent : isLight ? '#4b5563' : 'rgba(255,255,255,0.82)',
opacity, opacity,
transform: `translate3d(0, 0, 0) scale(${isActive ? 1.045 : isRelated ? 1.015 : 1})`, transform: `translate3d(0, 0, 0) scale(${isActive ? 1.045 : isRelated ? 1.015 : 1})`,
zIndex: isActive ? 30 : isRelated ? 20 : Math.round(node.ratio * 10), zIndex: isActive ? 30 : isRelated ? 20 : Math.round(node.ratio * 10),
@@ -346,21 +368,23 @@ export function TagAtlas({
onBlur={() => setActiveTag(null)} onBlur={() => setActiveTag(null)}
onClick={() => onSearch(node.entry.tag)} onClick={() => onSearch(node.entry.tag)}
> >
<span className="relative z-10 block w-full truncate text-center font-medium leading-none">{node.entry.tag}</span> <span className="relative z-10 block w-full truncate text-center leading-none font-medium">
{node.entry.tag}
</span>
<span <span
className={`absolute left-1/2 top-full z-10 mt-1 shrink-0 -translate-x-1/2 rounded-full px-1.5 py-0.5 text-[9px] tabular-nums shadow-sm backdrop-blur-sm transition-opacity ${ className={`absolute top-full left-1/2 z-10 mt-1 shrink-0 -translate-x-1/2 rounded-full px-1.5 py-0.5 text-[9px] tabular-nums shadow-sm backdrop-blur-sm transition-opacity ${
isActive || isRelated ? "opacity-100" : "opacity-0 group-hover:opacity-100" isActive || isRelated ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`} }`}
style={{ backgroundColor: `${node.accent}1A`, color: node.accent }} style={{ backgroundColor: `${node.accent}1A`, color: node.accent }}
> >
{connectedRelated ? connectedRelated.shared_count.toLocaleString() : node.entry.count.toLocaleString()} {connectedRelated
? connectedRelated.shared_count.toLocaleString()
: node.entry.count.toLocaleString()}
</span> </span>
</button> </button>
</Tooltip> </Tooltip>
); )
})} })}
</div> </div>
); )
} }
+171 -130
View File
@@ -1,19 +1,19 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from '@tanstack/react-virtual'
import type { ExploreTagEntry } from "../../store"; import type { ExploreTagEntry } from '../../store'
import { Dropdown } from "../menu"; import { Dropdown } from '../menu'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
type TagManageSort = "count_desc" | "count_asc" | "az" | "za"; type TagManageSort = 'count_desc' | 'count_asc' | 'az' | 'za'
const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [ const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [
{ value: "count_desc", label: "Most used" }, { value: 'count_desc', label: 'Most used' },
{ value: "count_asc", label: "Least used" }, { value: 'count_asc', label: 'Least used' },
{ value: "az", label: "A-Z" }, { value: 'az', label: 'A-Z' },
{ value: "za", label: "Z-A" }, { value: 'za', label: 'Z-A' },
]; ]
function AiSourceGlyph({ className = "" }: { className?: string }) { function AiSourceGlyph({ className = '' }: { className?: string }) {
return ( return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true"> <svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path <path
@@ -22,12 +22,15 @@ function AiSourceGlyph({ className = "" }: { className?: string }) {
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.35" strokeWidth="1.35"
/> />
<path d="M12.25 11.15l.25.74.75.26-.75.25-.25.75-.26-.75-.74-.25.74-.26.26-.74Z" fill="currentColor" /> <path
d="M12.25 11.15l.25.74.75.26-.75.25-.25.75-.26-.75-.74-.25.74-.26.26-.74Z"
fill="currentColor"
/>
</svg> </svg>
); )
} }
function RenameGlyph({ className = "" }: { className?: string }) { function RenameGlyph({ className = '' }: { className?: string }) {
return ( return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true"> <svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path <path
@@ -39,14 +42,19 @@ function RenameGlyph({ className = "" }: { className?: string }) {
/> />
<path d="M8.75 4.65l2.6 2.6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" /> <path d="M8.75 4.65l2.6 2.6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
</svg> </svg>
); )
} }
function DeleteGlyph({ className = "" }: { className?: string }) { function DeleteGlyph({ className = '' }: { className?: string }) {
return ( return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true"> <svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3.25 4.65h9.5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" /> <path d="M3.25 4.65h9.5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
<path d="M6.35 4.55V3.7c0-.55.45-1 1-1h1.3c.55 0 1 .45 1 1v.85" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" /> <path
d="M6.35 4.55V3.7c0-.55.45-1 1-1h1.3c.55 0 1 .45 1 1v.85"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.35"
/>
<path <path
d="M5.1 6.2l.35 5.65c.04.63.56 1.12 1.19 1.12h2.72c.63 0 1.15-.49 1.19-1.12l.35-5.65" d="M5.1 6.2l.35 5.65c.04.63.56 1.12 1.19 1.12h2.72c.63 0 1.15-.49 1.19-1.12l.35-5.65"
stroke="currentColor" stroke="currentColor"
@@ -55,7 +63,7 @@ function DeleteGlyph({ className = "" }: { className?: string }) {
strokeWidth="1.35" strokeWidth="1.35"
/> />
</svg> </svg>
); )
} }
// Compact management tile for a single tag. Rename doubles as merge when the new // Compact management tile for a single tag. Rename doubles as merge when the new
@@ -66,64 +74,67 @@ function TagManageTile({
onRename, onRename,
onDelete, onDelete,
}: { }: {
entry: ExploreTagEntry; entry: ExploreTagEntry
onSearch: (tag: string) => void; onSearch: (tag: string) => void
onRename: (from: string, to: string) => Promise<void>; onRename: (from: string, to: string) => Promise<void>
onDelete: (tag: string) => Promise<void>; onDelete: (tag: string) => Promise<void>
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false)
const [value, setValue] = useState(entry.tag); const [value, setValue] = useState(entry.tag)
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false)
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false)
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
if (editing) { if (editing) {
setValue(entry.tag); setValue(entry.tag)
setTimeout(() => inputRef.current?.select(), 0); setTimeout(() => inputRef.current?.select(), 0)
} }
}, [editing, entry.tag]); }, [editing, entry.tag])
const commitRename = async () => { const commitRename = async () => {
const next = value.trim(); const next = value.trim()
if (!next || next === entry.tag) { if (!next || next === entry.tag) {
setEditing(false); setEditing(false)
return; return
} }
setBusy(true); setBusy(true)
try { try {
await onRename(entry.tag, next); await onRename(entry.tag, next)
setEditing(false); setEditing(false)
} finally { } finally {
setBusy(false); setBusy(false)
} }
}; }
const sourceLabel = entry.has_ai_source const sourceLabel = entry.has_ai_source
? entry.has_user_source ? entry.has_user_source
? "Used by AI tags and user tags" ? 'Used by AI tags and user tags'
: "AI-generated tag" : 'AI-generated tag'
: "User tag"; : 'User tag'
return ( return (
<div <div
data-tag-manager-tile data-tag-manager-tile
className={`tag-manager-tile ${entry.has_ai_source ? "tag-manager-tile-ai" : ""} group relative min-w-0 rounded-lg border bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:bg-white/[0.045] hover:bg-white/[0.04] ${ className={`tag-manager-tile ${entry.has_ai_source ? 'tag-manager-tile-ai' : ''} group relative min-w-0 rounded-lg border bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:bg-white/[0.045] hover:bg-white/[0.04] ${
entry.has_ai_source entry.has_ai_source
? "border-white/[0.08] shadow-[inset_0_1px_0_rgba(125,211,252,0.055)] focus-within:border-white/[0.15] hover:border-white/[0.13]" ? 'border-white/[0.08] shadow-[inset_0_1px_0_rgba(125,211,252,0.055)] focus-within:border-white/[0.15] hover:border-white/[0.13]'
: "border-white/[0.06] focus-within:border-white/[0.14] hover:border-white/[0.12]" : 'border-white/[0.06] focus-within:border-white/[0.14] hover:border-white/[0.12]'
} ${editing || confirming ? "min-h-[82px]" : "min-h-[46px]"}`} } ${editing || confirming ? 'min-h-[82px]' : 'min-h-[46px]'}`}
> >
<div className="flex min-w-0 items-start gap-2"> <div className="flex min-w-0 items-start gap-2">
{editing ? ( {editing ? (
<input <input
ref={inputRef} ref={inputRef}
className="tag-manager-edit-input min-w-0 flex-1 rounded-md border border-blue-400/35 bg-black/25 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/30" className="tag-manager-edit-input min-w-0 flex-1 rounded-md border border-blue-400/35 bg-black/25 px-2 py-1 text-sm text-white ring-1 ring-blue-500/30 outline-none"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); } if (e.key === 'Enter') {
if (e.key === "Escape") setEditing(false); e.preventDefault()
void commitRename()
}
if (e.key === 'Escape') setEditing(false)
}} }}
disabled={busy} disabled={busy}
/> />
@@ -137,16 +148,16 @@ function TagManageTile({
</button> </button>
</Tooltip> </Tooltip>
)} )}
<div className="absolute right-2.5 top-2 shrink-0"> <div className="absolute top-2 right-2.5 shrink-0">
{entry.has_ai_source ? ( {entry.has_ai_source ? (
<Tooltip label={sourceLabel} delay={400} anchorToCursor> <Tooltip label={sourceLabel} delay={400} anchorToCursor>
<span className="tag-manager-count tag-manager-count-ai inline-flex h-5 items-center gap-1 rounded-full border border-sky-300/10 bg-sky-300/[0.075] px-1.5 text-[10px] tabular-nums text-sky-200/75"> <span className="tag-manager-count tag-manager-count-ai inline-flex h-5 items-center gap-1 rounded-full border border-sky-300/10 bg-sky-300/[0.075] px-1.5 text-[10px] text-sky-200/75 tabular-nums">
<AiSourceGlyph className="tag-manager-ai-glyph h-3 w-3" /> <AiSourceGlyph className="tag-manager-ai-glyph h-3 w-3" />
{entry.count.toLocaleString()} {entry.count.toLocaleString()}
</span> </span>
</Tooltip> </Tooltip>
) : ( ) : (
<span className="tag-manager-count rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] tabular-nums text-white/38"> <span className="tag-manager-count rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] text-white/38 tabular-nums">
{entry.count.toLocaleString()} {entry.count.toLocaleString()}
</span> </span>
)} )}
@@ -174,7 +185,15 @@ function TagManageTile({
<div className="mt-2 flex items-center gap-1"> <div className="mt-2 flex items-center gap-1">
<button <button
className="tag-manager-danger rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50" className="tag-manager-danger rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50"
onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }} onClick={async () => {
setBusy(true)
try {
await onDelete(entry.tag)
setConfirming(false)
} finally {
setBusy(false)
}
}}
disabled={busy} disabled={busy}
> >
Delete Delete
@@ -188,7 +207,7 @@ function TagManageTile({
</button> </button>
</div> </div>
) : ( ) : (
<div className="pointer-events-none absolute right-[5rem] top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"> <div className="pointer-events-none absolute top-1/2 right-[5rem] flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<Tooltip label="Rename or merge into another tag" delay={400} anchorToCursor> <Tooltip label="Rename or merge into another tag" delay={400} anchorToCursor>
<button <button
className="tag-manager-action inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 bg-gray-950/80 text-white/55 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80" className="tag-manager-action inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 bg-gray-950/80 text-white/55 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
@@ -210,7 +229,7 @@ function TagManageTile({
</div> </div>
)} )}
</div> </div>
); )
} }
export function TagManageList({ export function TagManageList({
@@ -221,88 +240,90 @@ export function TagManageList({
onResetAiTags, onResetAiTags,
scopeLabel, scopeLabel,
}: { }: {
entries: ExploreTagEntry[]; entries: ExploreTagEntry[]
onSearch: (tag: string) => void; onSearch: (tag: string) => void
onRename: (from: string, to: string) => Promise<void>; onRename: (from: string, to: string) => Promise<void>
onDelete: (tag: string) => Promise<void>; onDelete: (tag: string) => Promise<void>
onResetAiTags: () => Promise<number>; onResetAiTags: () => Promise<number>
scopeLabel: string; scopeLabel: string
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null); const measureRef = useRef<HTMLDivElement>(null)
const [query, setQuery] = useState(""); const [query, setQuery] = useState('')
const [sort, setSort] = useState<TagManageSort>("count_desc"); const [sort, setSort] = useState<TagManageSort>('count_desc')
const [columns, setColumns] = useState(3); const [columns, setColumns] = useState(3)
const [resetConfirming, setResetConfirming] = useState(false); const [resetConfirming, setResetConfirming] = useState(false)
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false)
const [resetStatus, setResetStatus] = useState<string | null>(null); const [resetStatus, setResetStatus] = useState<string | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
const el = measureRef.current; const el = measureRef.current
if (!el) return; if (!el) return
const update = () => { const update = () => {
const width = el.getBoundingClientRect().width; const width = el.getBoundingClientRect().width
setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1); setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1)
}; }
update(); update()
const ro = new ResizeObserver(update); const ro = new ResizeObserver(update)
ro.observe(el); ro.observe(el)
return () => ro.disconnect(); return () => ro.disconnect()
}, []); }, [])
const filteredEntries = useMemo(() => { const filteredEntries = useMemo(() => {
const needle = query.trim().toLowerCase(); const needle = query.trim().toLowerCase()
const filtered = needle const filtered = needle
? entries.filter((entry) => entry.tag.toLowerCase().includes(needle)) ? entries.filter((entry) => entry.tag.toLowerCase().includes(needle))
: entries; : entries
return [...filtered].sort((left, right) => { return [...filtered].sort((left, right) => {
switch (sort) { switch (sort) {
case "count_asc": case 'count_asc':
return left.count - right.count || left.tag.localeCompare(right.tag); return left.count - right.count || left.tag.localeCompare(right.tag)
case "az": case 'az':
return left.tag.localeCompare(right.tag); return left.tag.localeCompare(right.tag)
case "za": case 'za':
return right.tag.localeCompare(left.tag); return right.tag.localeCompare(left.tag)
case "count_desc": case 'count_desc':
default: default:
return right.count - left.count || left.tag.localeCompare(right.tag); return right.count - left.count || left.tag.localeCompare(right.tag)
} }
}); })
}, [entries, query, sort]); }, [entries, query, sort])
const rowCount = Math.ceil(filteredEntries.length / columns); const rowCount = Math.ceil(filteredEntries.length / columns)
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: rowCount, count: rowCount,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 54, estimateSize: () => 54,
overscan: 7, overscan: 7,
}); })
const visibleItems = rowVirtualizer.getVirtualItems(); const visibleItems = rowVirtualizer.getVirtualItems()
const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]); const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries])
const runResetAiTags = async () => { const runResetAiTags = async () => {
if (!resetConfirming) { if (!resetConfirming) {
setResetConfirming(true); setResetConfirming(true)
setResetStatus(`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`); setResetStatus(
return; `Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`
)
return
} }
setResetting(true); setResetting(true)
setResetStatus(null); setResetStatus(null)
try { try {
const count = await onResetAiTags(); const count = await onResetAiTags()
setResetStatus( setResetStatus(
count === 0 count === 0
? "No AI tag data found for this scope." ? 'No AI tag data found for this scope.'
: `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`, : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? '' : 's'}. Queue tagging when you're ready to retag.`
); )
} catch (error) { } catch (error) {
setResetStatus(String(error)); setResetStatus(String(error))
} finally { } finally {
setResetting(false); setResetting(false)
setResetConfirming(false); setResetConfirming(false)
} }
}; }
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -310,8 +331,12 @@ export function TagManageList({
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/32"> <div className="flex flex-wrap items-center gap-2 text-[11px] text-white/32">
<span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">{entries.length.toLocaleString()} tags</span> <span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">
<span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">{totalUses.toLocaleString()} uses</span> {entries.length.toLocaleString()} tags
</span>
<span className="tag-manager-stat rounded-full bg-white/[0.045] px-2 py-1 tabular-nums">
{totalUses.toLocaleString()} uses
</span>
{query.trim() ? ( {query.trim() ? (
<span className="tag-manager-match rounded-full bg-blue-500/10 px-2 py-1 text-blue-200/70 tabular-nums"> <span className="tag-manager-match rounded-full bg-blue-500/10 px-2 py-1 text-blue-200/70 tabular-nums">
{filteredEntries.length.toLocaleString()} matches {filteredEntries.length.toLocaleString()} matches
@@ -319,29 +344,32 @@ export function TagManageList({
) : null} ) : null}
</div> </div>
<p className="tag-manager-help mt-2 max-w-2xl text-[11px] leading-relaxed text-white/28"> <p className="tag-manager-help mt-2 max-w-2xl text-[11px] leading-relaxed text-white/28">
Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope. Rename tags to clean them up, rename into an existing tag to merge, or delete a tag
everywhere in the current scope.
</p> </p>
{resetStatus ? <p className="mt-2 text-[11px] leading-relaxed text-amber-200/80">{resetStatus}</p> : null} {resetStatus ? (
<p className="mt-2 text-[11px] leading-relaxed text-amber-200/80">{resetStatus}</p>
) : null}
</div> </div>
<div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2"> <div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
<button <button
className={`tag-manager-reset-button h-9 rounded-lg border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${ className={`tag-manager-reset-button h-9 rounded-lg border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
resetConfirming resetConfirming
? "tag-manager-reset-button-confirm border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25" ? 'tag-manager-reset-button-confirm border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25'
: "border-white/8 bg-black/20 text-white/50 hover:bg-white/[0.06] hover:text-white/80" : 'border-white/8 bg-black/20 text-white/50 hover:bg-white/[0.06] hover:text-white/80'
}`} }`}
onClick={() => void runResetAiTags()} onClick={() => void runResetAiTags()}
disabled={resetting} disabled={resetting}
> >
{resetting ? "Resetting..." : resetConfirming ? "Confirm reset" : "Reset AI tags"} {resetting ? 'Resetting...' : resetConfirming ? 'Confirm reset' : 'Reset AI tags'}
</button> </button>
{resetConfirming ? ( {resetConfirming ? (
<button <button
className="tag-manager-reset-cancel h-9 rounded-lg border border-white/8 bg-black/20 px-3 text-xs text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white/75 disabled:opacity-50" className="tag-manager-reset-cancel h-9 rounded-lg border border-white/8 bg-black/20 px-3 text-xs text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white/75 disabled:opacity-50"
onClick={() => { onClick={() => {
setResetConfirming(false); setResetConfirming(false)
setResetStatus(null); setResetStatus(null)
}} }}
disabled={resetting} disabled={resetting}
> >
@@ -350,21 +378,32 @@ export function TagManageList({
) : null} ) : null}
<div className="relative min-w-[220px] flex-1 sm:max-w-sm"> <div className="relative min-w-[220px] flex-1 sm:max-w-sm">
<input <input
className="tag-manager-filter h-9 w-full rounded-lg border border-white/8 bg-black/20 px-3 pr-8 text-sm text-white/85 outline-none transition-colors placeholder:text-white/22 focus:border-blue-400/35 focus:bg-black/28" className="tag-manager-filter h-9 w-full rounded-lg border border-white/8 bg-black/20 px-3 pr-8 text-sm text-white/85 transition-colors outline-none placeholder:text-white/22 focus:border-blue-400/35 focus:bg-black/28"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Filter tags" placeholder="Filter tags"
/> />
{query ? ( {query ? (
<span className="absolute right-2 top-2 z-10"> <span className="absolute top-2 right-2 z-10">
<Tooltip label="Clear filter" delay={400} anchorToCursor> <Tooltip label="Clear filter" delay={400} anchorToCursor>
<button <button
className="tag-manager-clear inline-flex h-5 w-5 items-center justify-center rounded-md text-white/35 transition-colors hover:bg-white/8 hover:text-white/75 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/70" className="tag-manager-clear inline-flex h-5 w-5 items-center justify-center rounded-md text-white/35 transition-colors hover:bg-white/8 hover:text-white/75 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/70"
onClick={() => setQuery("")} onClick={() => setQuery('')}
aria-label="Clear tag filter" aria-label="Clear tag filter"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M6 6l12 12M18 6L6 18" /> className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.2}
d="M6 6l12 12M18 6L6 18"
/>
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>
@@ -382,7 +421,11 @@ export function TagManageList({
</div> </div>
</div> </div>
<div ref={scrollRef} data-tag-manager-scroll className="min-h-0 flex-1 overflow-y-auto px-6 py-5"> <div
ref={scrollRef}
data-tag-manager-scroll
className="min-h-0 flex-1 overflow-y-auto px-6 py-5"
>
<div ref={measureRef} className="mx-auto w-full max-w-7xl"> <div ref={measureRef} className="mx-auto w-full max-w-7xl">
{filteredEntries.length === 0 ? ( {filteredEntries.length === 0 ? (
<div className="tag-manager-empty flex h-48 items-center justify-center rounded-lg border border-white/[0.06] bg-white/[0.02] text-sm text-white/30"> <div className="tag-manager-empty flex h-48 items-center justify-center rounded-lg border border-white/[0.06] bg-white/[0.02] text-sm text-white/30">
@@ -394,14 +437,14 @@ export function TagManageList({
style={{ height: `${rowVirtualizer.getTotalSize()}px` }} style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
> >
{visibleItems.map((virtualRow) => { {visibleItems.map((virtualRow) => {
const start = virtualRow.index * columns; const start = virtualRow.index * columns
const rowEntries = filteredEntries.slice(start, start + columns); const rowEntries = filteredEntries.slice(start, start + columns)
return ( return (
<div <div
key={virtualRow.key} key={virtualRow.key}
data-index={virtualRow.index} data-index={virtualRow.index}
ref={rowVirtualizer.measureElement} ref={rowVirtualizer.measureElement}
className="absolute left-0 top-0 grid w-full gap-x-2 pb-2" className="absolute top-0 left-0 grid w-full gap-x-2 pb-2"
style={{ style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
@@ -417,14 +460,12 @@ export function TagManageList({
/> />
))} ))}
</div> </div>
); )
})} })}
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
); )
} }
+25 -25
View File
@@ -1,34 +1,34 @@
export const ACCENTS = [ export const ACCENTS = [
"#60a5fa", '#60a5fa',
"#c084fc", '#c084fc',
"#4ade80", '#4ade80',
"#fbbf24", '#fbbf24',
"#f472b4", '#f472b4',
"#2dd4bf", '#2dd4bf',
"#fb923c", '#fb923c',
"#a78bfa", '#a78bfa',
"#34d399", '#34d399',
"#f87171", '#f87171',
]; ]
// Darker variants of each accent for the light theme -- the bright originals are // Darker variants of each accent for the light theme -- the bright originals are
// tuned for dark cards and wash out on the cream background. // tuned for dark cards and wash out on the cream background.
export const LIGHT_ACCENTS = [ export const LIGHT_ACCENTS = [
"#2563eb", '#2563eb',
"#9333ea", '#9333ea',
"#16a34a", '#16a34a',
"#d97706", '#d97706',
"#db2777", '#db2777',
"#0d9488", '#0d9488',
"#ea580c", '#ea580c',
"#7c3aed", '#7c3aed',
"#059669", '#059669',
"#dc2626", '#dc2626',
]; ]
export const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); export const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
export function seeded(n: number): number { export function seeded(n: number): number {
const x = Math.sin(n * 9301 + 49297) * 233280; const x = Math.sin(n * 9301 + 49297) * 233280
return x - Math.floor(x); return x - Math.floor(x)
} }
+24 -20
View File
@@ -1,6 +1,6 @@
import { DirEntry } from "../../store"; import { DirEntry } from '../../store'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { CheckIcon, ChevronRightIcon } from "../icons"; import { CheckIcon, ChevronRightIcon } from '../icons'
export function FolderRow({ export function FolderRow({
entry, entry,
@@ -9,29 +9,29 @@ export function FolderRow({
onToggle, onToggle,
onNavigate, onNavigate,
}: { }: {
entry: DirEntry; entry: DirEntry
selected: boolean; selected: boolean
alreadyAdded: boolean; alreadyAdded: boolean
onToggle: () => void; onToggle: () => void
onNavigate: () => void; onNavigate: () => void
}) { }) {
return ( return (
<div <div
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${ className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
alreadyAdded alreadyAdded
? "border-transparent bg-white/[0.025] text-gray-600 light-theme:bg-gray-900 light-theme:text-gray-500" ? 'light-theme:bg-gray-900 light-theme:text-gray-500 border-transparent bg-white/[0.025] text-gray-600'
: selected : selected
? "border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)] light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white" ? 'light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)]'
: "border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white" : 'light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white'
}`} }`}
> >
<button <button
type="button" type="button"
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${ className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
selected selected
? "border-white/30 bg-gray-200 text-gray-950 light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white" ? 'light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white border-white/30 bg-gray-200 text-gray-950'
: "border-white/15 bg-white/[0.035] text-transparent hover:border-white/30 light-theme:border-gray-700/50 light-theme:bg-gray-950" : 'light-theme:border-gray-700/50 light-theme:bg-gray-950 border-white/15 bg-white/[0.035] text-transparent hover:border-white/30'
} ${alreadyAdded ? "cursor-not-allowed opacity-40" : ""}`} } ${alreadyAdded ? 'cursor-not-allowed opacity-40' : ''}`}
onClick={onToggle} onClick={onToggle}
disabled={alreadyAdded} disabled={alreadyAdded}
aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`} aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`}
@@ -45,7 +45,11 @@ export function FolderRow({
className="flex w-full min-w-0 items-center gap-2 text-left" className="flex w-full min-w-0 items-center gap-2 text-left"
onClick={onNavigate} onClick={onNavigate}
> >
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24"> <svg
className="h-4 w-4 shrink-0 text-amber-300/90"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" /> <path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg> </svg>
<span className="truncate text-sm">{entry.name}</span> <span className="truncate text-sm">{entry.name}</span>
@@ -53,20 +57,20 @@ export function FolderRow({
</Tooltip> </Tooltip>
{alreadyAdded ? ( {alreadyAdded ? (
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950"> <span className="light-theme:border-gray-700/40 light-theme:bg-gray-950 rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500">
Added Added
</span> </span>
) : null} ) : null}
<Tooltip label={entry.has_children ? "Open folder" : "No subfolders"} anchorToCursor> <Tooltip label={entry.has_children ? 'Open folder' : 'No subfolders'} anchorToCursor>
<button <button
type="button" type="button"
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white" className="light-theme:hover:bg-gray-900 light-theme:hover:text-white rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={onNavigate} onClick={onNavigate}
> >
<ChevronRightIcon className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} /> <ChevronRightIcon className={`h-4 w-4 ${entry.has_children ? '' : 'opacity-45'}`} />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
); )
} }
@@ -1,26 +1,26 @@
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { CloseIcon } from "../icons"; import { CloseIcon } from '../icons'
import { folderName } from "./pathUtils"; import { folderName } from './pathUtils'
export function StagedFoldersPanel({ export function StagedFoldersPanel({
stagedPaths, stagedPaths,
onRemove, onRemove,
onClear, onClear,
}: { }: {
stagedPaths: string[]; stagedPaths: string[]
onRemove: (path: string) => void; onRemove: (path: string) => void
onClear: () => void; onClear: () => void
}) { }) {
return ( return (
<aside className="flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] light-theme:border-gray-300/70 light-theme:bg-gray-900/35 lg:w-80 lg:border-l lg:border-t-0"> <aside className="light-theme:border-gray-300/70 light-theme:bg-gray-900/35 flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] lg:w-80 lg:border-t-0 lg:border-l">
<div className="flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-300/70"> <div className="light-theme:border-gray-300/70 flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500"> <p className="text-[11px] font-semibold tracking-[0.16em] text-gray-500 uppercase">
Folders to add ({stagedPaths.length}) Folders to add ({stagedPaths.length})
</p> </p>
{stagedPaths.length > 0 ? ( {stagedPaths.length > 0 ? (
<button <button
type="button" type="button"
className="rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={onClear} onClick={onClear}
> >
Clear all Clear all
@@ -30,9 +30,9 @@ export function StagedFoldersPanel({
<div className="min-h-0 flex-1 overflow-y-auto p-3"> <div className="min-h-0 flex-1 overflow-y-auto p-3">
{stagedPaths.length === 0 ? ( {stagedPaths.length === 0 ? (
<div className="flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center light-theme:border-gray-700/35"> <div className="light-theme:border-gray-700/35 flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center">
<p className="text-sm text-gray-500">No folders selected.</p> <p className="text-sm text-gray-500">No folders selected.</p>
<p className="mt-1 max-w-52 text-xs leading-relaxed text-gray-600 light-theme:text-gray-500"> <p className="light-theme:text-gray-500 mt-1 max-w-52 text-xs leading-relaxed text-gray-600">
Choose folders on the left and they will collect here. Choose folders on the left and they will collect here.
</p> </p>
</div> </div>
@@ -40,18 +40,24 @@ export function StagedFoldersPanel({
<div className="space-y-2"> <div className="space-y-2">
{stagedPaths.map((path) => ( {stagedPaths.map((path) => (
<Tooltip key={path} label={path} anchorToCursor block> <Tooltip key={path} label={path} anchorToCursor block>
<div className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"> <div className="group light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07]">
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24"> <svg
className="h-4 w-4 shrink-0 text-amber-300/90"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" /> <path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg> </svg>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{folderName(path)}</p> <p className="truncate text-sm font-medium">{folderName(path)}</p>
<p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p> <p className="light-theme:text-gray-500 mt-0.5 truncate text-[11px] text-gray-600">
{path}
</p>
</div> </div>
<Tooltip label="Remove from folders to add" anchorToCursor> <Tooltip label="Remove from folders to add" anchorToCursor>
<button <button
type="button" type="button"
className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700" className="light-theme:hover:bg-gray-700 rounded-md p-1 text-gray-500 opacity-80 transition-colors group-hover:opacity-100 hover:bg-white/[0.08] hover:text-white"
onClick={() => onRemove(path)} onClick={() => onRemove(path)}
aria-label={`Remove ${path} from folders to add`} aria-label={`Remove ${path} from folders to add`}
> >
@@ -65,5 +71,5 @@ export function StagedFoldersPanel({
)} )}
</div> </div>
</aside> </aside>
); )
} }
+7 -7
View File
@@ -1,13 +1,13 @@
import { FolderAddResult } from "../../store"; import { FolderAddResult } from '../../store'
export function StatusLine({ results }: { results: FolderAddResult[] | null }) { export function StatusLine({ results }: { results: FolderAddResult[] | null }) {
if (!results) return null; if (!results) return null
const added = results.filter((result) => result.status === "added").length; const added = results.filter((result) => result.status === 'added').length
const skipped = results.filter((result) => result.status === "skipped").length; const skipped = results.filter((result) => result.status === 'skipped').length
const failed = results.filter((result) => result.status === "error").length; const failed = results.filter((result) => result.status === 'error').length
return ( return (
<p className="text-xs text-gray-500 light-theme:text-gray-600"> <p className="light-theme:text-gray-600 text-xs text-gray-500">
Added {added}, skipped {skipped}, failed {failed}. Added {added}, skipped {skipped}, failed {failed}.
</p> </p>
); )
} }
+35 -32
View File
@@ -1,66 +1,69 @@
export interface Breadcrumb { export interface Breadcrumb {
label: string; label: string
path: string | null; path: string | null
} }
export function normalizePath(path: string): string { export function normalizePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
} }
export function cleanAddressInput(path: string): string { export function cleanAddressInput(path: string): string {
const trimmed = path.trim(); const trimmed = path.trim()
if (trimmed.length >= 2) { if (trimmed.length >= 2) {
const first = trimmed[0]; const first = trimmed[0]
const last = trimmed[trimmed.length - 1]; const last = trimmed[trimmed.length - 1]
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) { if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return trimmed.slice(1, -1).trim(); return trimmed.slice(1, -1).trim()
} }
} }
return trimmed; return trimmed
} }
export function friendlyDirectoryError(error: unknown): string { export function friendlyDirectoryError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error)
if (/cannot find the path|os error 3|not found|no such file/i.test(message)) { if (/cannot find the path|os error 3|not found|no such file/i.test(message)) {
return "Folder not found. Check the path and try again."; return 'Folder not found. Check the path and try again.'
} }
return message; return message
} }
export function folderName(path: string): string { export function folderName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, ""); const trimmed = path.replace(/[\\/]+$/, '')
const parts = trimmed.split(/[\\/]+/).filter(Boolean); const parts = trimmed.split(/[\\/]+/).filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : path; return parts.length > 0 ? parts[parts.length - 1] : path
} }
export function buildBreadcrumbs(path: string | null): Breadcrumb[] { export function buildBreadcrumbs(path: string | null): Breadcrumb[] {
if (!path) return [{ label: "This PC / Home", path: null }]; if (!path) return [{ label: 'This PC / Home', path: null }]
const separator = path.includes("\\") ? "\\" : "/"; const separator = path.includes('\\') ? '\\' : '/'
const normalized = path.replace(/[\\/]+$/, ""); const normalized = path.replace(/[\\/]+$/, '')
const windowsDrive = normalized.match(/^[A-Za-z]:/); const windowsDrive = normalized.match(/^[A-Za-z]:/)
if (windowsDrive) { if (windowsDrive) {
const drive = windowsDrive[0]; const drive = windowsDrive[0]
const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean); const rest = normalized
let current = drive; .slice(2)
.split(/[\\/]+/)
.filter(Boolean)
let current = drive
return [ return [
{ label: "This PC", path: null }, { label: 'This PC', path: null },
{ label: drive, path: drive }, { label: drive, path: drive },
...rest.map((part) => { ...rest.map((part) => {
current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`; current = current.endsWith('\\') ? `${current}${part}` : `${current}\\${part}`
return { label: part, path: current }; return { label: part, path: current }
}), }),
]; ]
} }
const parts = normalized.split(/[\\/]+/).filter(Boolean); const parts = normalized.split(/[\\/]+/).filter(Boolean)
let current = separator === "/" ? "" : ""; let current = separator === '/' ? '' : ''
return [ return [
{ label: "/", path: null }, { label: '/', path: null },
...parts.map((part) => { ...parts.map((part) => {
current = `${current}/${part}`; current = `${current}/${part}`
return { label: part, path: current }; return { label: part, path: current }
}), }),
]; ]
} }
+127 -120
View File
@@ -1,183 +1,190 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from 'react'
import { DirListing, FolderAddResult, useGalleryStore } from "../../store"; import { DirListing, FolderAddResult, useGalleryStore } from '../../store'
import { import {
buildBreadcrumbs, buildBreadcrumbs,
cleanAddressInput, cleanAddressInput,
friendlyDirectoryError, friendlyDirectoryError,
normalizePath, normalizePath,
} from "./pathUtils"; } from './pathUtils'
export function useFolderPicker() { export function useFolderPicker() {
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen); const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen)
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen)
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders)
const listDirectories = useGalleryStore((state) => state.listDirectories); const listDirectories = useGalleryStore((state) => state.listDirectories)
const addFolders = useGalleryStore((state) => state.addFolders); const addFolders = useGalleryStore((state) => state.addFolders)
const [listing, setListing] = useState<DirListing | null>(null); const [listing, setListing] = useState<DirListing | null>(null)
const [currentPath, setCurrentPath] = useState<string | null>(null); const [currentPath, setCurrentPath] = useState<string | null>(null)
const [addressDraft, setAddressDraft] = useState(""); const [addressDraft, setAddressDraft] = useState('')
const [addressEditing, setAddressEditing] = useState(false); const [addressEditing, setAddressEditing] = useState(false)
const [stagedPaths, setStagedPaths] = useState<string[]>([]); const [stagedPaths, setStagedPaths] = useState<string[]>([])
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false)
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null)
const [results, setResults] = useState<FolderAddResult[] | null>(null); const [results, setResults] = useState<FolderAddResult[] | null>(null)
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null)
const addressInputRef = useRef<HTMLInputElement>(null); const addressInputRef = useRef<HTMLInputElement>(null)
const libraryPaths = useMemo(() => new Set(folders.map((folder) => normalizePath(folder.path))), [folders]); const libraryPaths = useMemo(
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths]); () => new Set(folders.map((folder) => normalizePath(folder.path))),
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current]); [folders]
)
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths])
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current])
useEffect(() => { useEffect(() => {
if (!folderPickerOpen) return; if (!folderPickerOpen) return
let cancelled = false; let cancelled = false
setLoading(true); setLoading(true)
setError(null); setError(null)
void listDirectories(currentPath) void listDirectories(currentPath)
.then((nextListing) => { .then((nextListing) => {
if (cancelled) return; if (cancelled) return
setListing(nextListing); setListing(nextListing)
setAddressDraft(nextListing.current ?? ""); setAddressDraft(nextListing.current ?? '')
setAddressEditing(false); setAddressEditing(false)
scrollRef.current?.scrollTo({ top: 0, left: 0 }); scrollRef.current?.scrollTo({ top: 0, left: 0 })
}) })
.catch((loadError) => { .catch((loadError) => {
if (cancelled) return; if (cancelled) return
setListing({ current: currentPath, parent: null, entries: [] }); setListing({ current: currentPath, parent: null, entries: [] })
setError(friendlyDirectoryError(loadError)); setError(friendlyDirectoryError(loadError))
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false)
}); })
return () => { return () => {
cancelled = true; cancelled = true
}; }
}, [currentPath, folderPickerOpen, listDirectories]); }, [currentPath, folderPickerOpen, listDirectories])
useEffect(() => { useEffect(() => {
if (!folderPickerOpen) return; if (!folderPickerOpen) return
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === 'Escape') {
if (addressEditing) { if (addressEditing) {
setAddressDraft(listing?.current ?? ""); setAddressDraft(listing?.current ?? '')
setAddressEditing(false); setAddressEditing(false)
return; return
} }
setFolderPickerOpen(false); setFolderPickerOpen(false)
} }
}; }
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown)
}, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen]); }, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen])
useEffect(() => { useEffect(() => {
if (!addressEditing) return; if (!addressEditing) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
addressInputRef.current?.focus(); addressInputRef.current?.focus()
addressInputRef.current?.select(); addressInputRef.current?.select()
}); })
}, [addressEditing]); }, [addressEditing])
useEffect(() => { useEffect(() => {
if (folderPickerOpen) return; if (folderPickerOpen) return
setCurrentPath(null); setCurrentPath(null)
setAddressDraft(""); setAddressDraft('')
setAddressEditing(false); setAddressEditing(false)
setListing(null); setListing(null)
setStagedPaths([]); setStagedPaths([])
setError(null); setError(null)
setResults(null); setResults(null)
setAdding(false); setAdding(false)
}, [folderPickerOpen]); }, [folderPickerOpen])
const entries = listing?.entries ?? []; const entries = listing?.entries ?? []
const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? "")); const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? ''))
const normalizedAddressPath = addressPath ? normalizePath(addressPath) : ""; const normalizedAddressPath = addressPath ? normalizePath(addressPath) : ''
const addressAlreadyAdded = normalizedAddressPath ? libraryPaths.has(normalizedAddressPath) : false; const addressAlreadyAdded = normalizedAddressPath
const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false; ? libraryPaths.has(normalizedAddressPath)
: false
const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false
const togglePath = (path: string) => { const togglePath = (path: string) => {
const normalized = normalizePath(path); const normalized = normalizePath(path)
if (libraryPaths.has(normalized)) return; if (libraryPaths.has(normalized)) return
setResults(null); setResults(null)
setStagedPaths((current) => { setStagedPaths((current) => {
const exists = current.some((staged) => normalizePath(staged) === normalized); const exists = current.some((staged) => normalizePath(staged) === normalized)
return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path]; return exists
}); ? current.filter((staged) => normalizePath(staged) !== normalized)
}; : [...current, path]
})
}
const stagePath = (path: string) => { const stagePath = (path: string) => {
const cleaned = cleanAddressInput(path); const cleaned = cleanAddressInput(path)
if (!cleaned) { if (!cleaned) {
setError("Enter a folder path first."); setError('Enter a folder path first.')
return; return
} }
const normalized = normalizePath(cleaned); const normalized = normalizePath(cleaned)
if (libraryPaths.has(normalized)) { if (libraryPaths.has(normalized)) {
setError("That folder is already in your library."); setError('That folder is already in your library.')
return; return
} }
if (stagedSet.has(normalized)) { if (stagedSet.has(normalized)) {
setError("That folder is already selected."); setError('That folder is already selected.')
return; return
} }
setError(null); setError(null)
setResults(null); setResults(null)
setStagedPaths((current) => [...current, cleaned]); setStagedPaths((current) => [...current, cleaned])
}; }
const navigateToAddress = () => { const navigateToAddress = () => {
const cleaned = cleanAddressInput(addressDraft); const cleaned = cleanAddressInput(addressDraft)
setResults(null); setResults(null)
setError(null); setError(null)
setCurrentPath(cleaned || null); setCurrentPath(cleaned || null)
}; }
const updateAddressDraft = (nextDraft: string) => { const updateAddressDraft = (nextDraft: string) => {
setAddressDraft(nextDraft); setAddressDraft(nextDraft)
setResults(null); setResults(null)
}; }
const beginAddressEdit = () => { const beginAddressEdit = () => {
setAddressDraft(listing?.current ?? ""); setAddressDraft(listing?.current ?? '')
setAddressEditing(true); setAddressEditing(true)
}; }
const removeStagedPath = (path: string) => { const removeStagedPath = (path: string) => {
const normalized = normalizePath(path); const normalized = normalizePath(path)
setResults(null); setResults(null)
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized)); setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized))
}; }
const clearStagedPaths = () => { const clearStagedPaths = () => {
setResults(null); setResults(null)
setStagedPaths([]); setStagedPaths([])
}; }
const confirmAdd = async () => { const confirmAdd = async () => {
if (stagedPaths.length === 0 || adding) return; if (stagedPaths.length === 0 || adding) return
setAdding(true); setAdding(true)
setError(null); setError(null)
try { try {
const addResults = await addFolders(stagedPaths); const addResults = await addFolders(stagedPaths)
const failed = addResults.filter((result) => result.status === "error"); const failed = addResults.filter((result) => result.status === 'error')
setResults(addResults); setResults(addResults)
if (failed.length > 0) { if (failed.length > 0) {
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error")); setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === 'error'))
setError(failed.map((failure) => failure.data).join("; ")); setError(failed.map((failure) => failure.data).join('; '))
return; return
} }
setFolderPickerOpen(false); setFolderPickerOpen(false)
} catch (addError) { } catch (addError) {
setError(addError instanceof Error ? addError.message : String(addError)); setError(addError instanceof Error ? addError.message : String(addError))
} finally { } finally {
setAdding(false); setAdding(false)
} }
}; }
return { return {
adding, adding,
@@ -208,5 +215,5 @@ export function useFolderPicker() {
stagedSet, stagedSet,
togglePath, togglePath,
updateAddressDraft, updateAddressDraft,
}; }
} }
+32 -32
View File
@@ -1,12 +1,12 @@
import { ParsedSearch } from "../../store"; import { ParsedSearch } from '../../store'
import { PhotoIcon } from "../icons"; import { PhotoIcon } from '../icons'
export function GalleryLoadingState({ export function GalleryLoadingState({
isSimilarResults, isSimilarResults,
parsedSearch, parsedSearch,
}: { }: {
isSimilarResults: boolean; isSimilarResults: boolean
parsedSearch: ParsedSearch; parsedSearch: ParsedSearch
}) { }) {
return ( return (
<div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center"> <div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
@@ -14,25 +14,25 @@ export function GalleryLoadingState({
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white/60" /> <div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
<p className="mt-4 text-sm font-medium text-white/40"> <p className="mt-4 text-sm font-medium text-white/40">
{isSimilarResults {isSimilarResults
? "Finding similar images" ? 'Finding similar images'
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 : parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? `Searching for matches to "${parsedSearch.query}"` ? `Searching for matches to "${parsedSearch.query}"`
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0 : parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? `Searching tags for "${parsedSearch.query}"` ? `Searching tags for "${parsedSearch.query}"`
: "Loading media"} : 'Loading media'}
</p> </p>
<p className="mt-1 text-xs text-white/20"> <p className="mt-1 text-xs text-white/20">
{isSimilarResults {isSimilarResults
? "Comparing visual embeddings" ? 'Comparing visual embeddings'
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 : parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? "Semantic search can take a little longer than filename search" ? 'Semantic search can take a little longer than filename search'
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0 : parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? "Matching against AI and user tags" ? 'Matching against AI and user tags'
: "Fetching results"} : 'Fetching results'}
</p> </p>
</div> </div>
</div> </div>
); )
} }
export function GalleryEmptyState({ export function GalleryEmptyState({
@@ -40,9 +40,9 @@ export function GalleryEmptyState({
isSimilarResults, isSimilarResults,
parsedSearch, parsedSearch,
}: { }: {
imageLoadError: string | null; imageLoadError: string | null
isSimilarResults: boolean; isSimilarResults: boolean
parsedSearch: ParsedSearch; parsedSearch: ParsedSearch
}) { }) {
return ( return (
<div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center"> <div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
@@ -50,27 +50,27 @@ export function GalleryEmptyState({
<PhotoIcon className="mx-auto mb-4 h-12 w-12 text-white/10" strokeWidth={0.75} /> <PhotoIcon className="mx-auto mb-4 h-12 w-12 text-white/10" strokeWidth={0.75} />
<p className="text-sm font-medium text-white/30"> <p className="text-sm font-medium text-white/30">
{imageLoadError {imageLoadError
? "Could not load results" ? 'Could not load results'
: isSimilarResults : isSimilarResults
? "No similar images found" ? 'No similar images found'
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 : parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? "No semantic matches found" ? 'No semantic matches found'
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0 : parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? "No tag matches found" ? 'No tag matches found'
: "No media found"} : 'No media found'}
</p> </p>
<p className="mt-1 text-xs text-white/15"> <p className="mt-1 text-xs text-white/15">
{imageLoadError {imageLoadError
? imageLoadError ? imageLoadError
: isSimilarResults : isSimilarResults
? "This item may be visually isolated, or more embeddings may need to finish processing" ? 'This item may be visually isolated, or more embeddings may need to finish processing'
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 : parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? "Try a broader phrase, or wait for more embeddings to finish processing" ? 'Try a broader phrase, or wait for more embeddings to finish processing'
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0 : parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? "Try a shorter tag, or wait for more tagging jobs to finish" ? 'Try a shorter tag, or wait for more tagging jobs to finish'
: "Try adjusting your filters or add a new folder"} : 'Try adjusting your filters or add a new folder'}
</p> </p>
</div> </div>
</div> </div>
); )
} }
+50 -46
View File
@@ -1,36 +1,36 @@
import { useState } from "react"; import { useState } from 'react'
import { ImageRecord, useGalleryStore } from "../../store"; import { ImageRecord, useGalleryStore } from '../../store'
import { mediaSrc } from "../../lib/mediaSrc"; import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { CheckIcon, PhotoIcon, PlayIcon, StarIcon, WarningIcon } from "../icons"; import { CheckIcon, PhotoIcon, PlayIcon, StarIcon, WarningIcon } from '../icons'
import { formatDuration } from "./format"; import { formatDuration } from './format'
import { TruncatedFilename } from "./TruncatedFilename"; import { TruncatedFilename } from './TruncatedFilename'
export function ImageTile({ export function ImageTile({
image, image,
onClick, onClick,
onContextMenu, onContextMenu,
}: { }: {
image: ImageRecord; image: ImageRecord
onClick: () => void; onClick: () => void
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void; onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void
}) { }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false)
const [errored, setErrored] = useState(false); const [errored, setErrored] = useState(false)
const findSimilar = useGalleryStore((state) => state.findSimilar); const findSimilar = useGalleryStore((state) => state.findSimilar)
const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id)); const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id))
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0); const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0)
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected); const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected)
const canFindSimilar = image.embedding_status === "ready"; const canFindSimilar = image.embedding_status === 'ready'
const src = mediaSrc(image.thumbnail_path); const src = mediaSrc(image.thumbnail_path)
return ( return (
<div <div
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left transition-shadow focus:outline-none ${ className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left transition-shadow focus:outline-none ${
selected ? "ring-2 ring-inset ring-blue-400/80" : "" selected ? 'ring-2 ring-blue-400/80 ring-inset' : ''
}`} }`}
style={{ width: "100%", aspectRatio: "1 / 1" }} style={{ width: '100%', aspectRatio: '1 / 1' }}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
<button <button
@@ -38,31 +38,31 @@ export function ImageTile({
className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80" className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
aria-label={`Open ${image.filename}`} aria-label={`Open ${image.filename}`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
if (selectionActive) toggleGallerySelected(image.id); if (selectionActive) toggleGallerySelected(image.id)
else onClick(); else onClick()
}} }}
onDoubleClick={(event) => { onDoubleClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onClick(); onClick()
}} }}
/> />
<button <button
type="button" type="button"
role="checkbox" role="checkbox"
aria-checked={selected} aria-checked={selected}
aria-label={selected ? "Deselect" : "Select"} aria-label={selected ? 'Deselect' : 'Select'}
className="group/cb absolute left-0 top-0 z-20 h-11 w-11 cursor-pointer" className="group/cb absolute top-0 left-0 z-20 h-11 w-11 cursor-pointer"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
toggleGallerySelected(image.id); toggleGallerySelected(image.id)
}} }}
> >
<div <div
className={`absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${ className={`absolute top-2 left-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${
selected selected
? "border-blue-400 bg-blue-500 text-white opacity-100" ? 'border-blue-400 bg-blue-500 text-white opacity-100'
: "border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100" : 'border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100'
}`} }`}
> >
<CheckIcon className="h-3 w-3" strokeWidth={3} /> <CheckIcon className="h-3 w-3" strokeWidth={3} />
@@ -76,7 +76,7 @@ export function ImageTile({
src={src} src={src}
alt={image.filename} alt={image.filename}
className={`h-full w-full object-cover transition-all duration-300 ${ className={`h-full w-full object-cover transition-all duration-300 ${
loaded ? "scale-100 opacity-100" : "scale-[1.02] opacity-0" loaded ? 'scale-100 opacity-100' : 'scale-[1.02] opacity-0'
} group-hover:scale-[1.03]`} } group-hover:scale-[1.03]`}
loading="lazy" loading="lazy"
onLoad={() => setLoaded(true)} onLoad={() => setLoaded(true)}
@@ -85,7 +85,7 @@ export function ImageTile({
</> </>
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03] text-white/20"> <div className="absolute inset-0 flex items-center justify-center bg-white/[0.03] text-white/20">
{image.media_kind === "video" ? ( {image.media_kind === 'video' ? (
<PlayIcon className="h-7 w-7" /> <PlayIcon className="h-7 w-7" />
) : ( ) : (
<PhotoIcon className="h-7 w-7" strokeWidth={1} /> <PhotoIcon className="h-7 w-7" strokeWidth={1} />
@@ -93,7 +93,7 @@ export function ImageTile({
</div> </div>
)} )}
{image.media_kind === "video" ? ( {image.media_kind === 'video' ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="rounded-full bg-black/40 p-3 text-white opacity-50 backdrop-blur-sm transition-opacity duration-200 group-hover:opacity-90"> <div className="rounded-full bg-black/40 p-3 text-white opacity-50 backdrop-blur-sm transition-opacity duration-200 group-hover:opacity-90">
<PlayIcon className="h-5 w-5" /> <PlayIcon className="h-5 w-5" />
@@ -101,9 +101,13 @@ export function ImageTile({
</div> </div>
) : null} ) : null}
<div className="pointer-events-none absolute right-2 top-2 flex flex-col items-end gap-1"> <div className="pointer-events-none absolute top-2 right-2 flex flex-col items-end gap-1">
{image.embedding_status === "failed" ? ( {image.embedding_status === 'failed' ? (
<Tooltip label={image.embedding_error ?? "Embedding failed"} followCursor className="pointer-events-auto"> <Tooltip
label={image.embedding_error ?? 'Embedding failed'}
followCursor
className="pointer-events-auto"
>
<div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm"> <div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
<WarningIcon className="h-2.5 w-2.5 shrink-0" strokeWidth={2.5} /> <WarningIcon className="h-2.5 w-2.5 shrink-0" strokeWidth={2.5} />
</div> </div>
@@ -123,7 +127,7 @@ export function ImageTile({
))} ))}
</div> </div>
) : null} ) : null}
{image.media_kind === "video" && image.duration_ms ? ( {image.media_kind === 'video' && image.duration_ms ? (
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm"> <div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
{formatDuration(image.duration_ms)} {formatDuration(image.duration_ms)}
</div> </div>
@@ -132,7 +136,7 @@ export function ImageTile({
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" /> <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
<div className="absolute bottom-0 left-0 right-0 z-20 translate-y-1 p-2.5 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"> <div className="absolute right-0 bottom-0 left-0 z-20 translate-y-1 p-2.5 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
<TruncatedFilename filename={image.filename} /> <TruncatedFilename filename={image.filename} />
<div className="mt-1.5 flex items-center justify-between gap-2"> <div className="mt-1.5 flex items-center justify-between gap-2">
{image.rating > 0 ? ( {image.rating > 0 ? (
@@ -147,13 +151,13 @@ export function ImageTile({
<button <button
className={`pointer-events-auto relative z-20 rounded-md px-2 py-0.5 text-[10px] backdrop-blur-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${ className={`pointer-events-auto relative z-20 rounded-md px-2 py-0.5 text-[10px] backdrop-blur-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${
canFindSimilar canFindSimilar
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white" ? 'bg-white/10 text-white/80 hover:bg-white/20 hover:text-white'
: "cursor-not-allowed bg-white/5 text-white/30" : 'cursor-not-allowed bg-white/5 text-white/30'
}`} }`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
if (!canFindSimilar) return; if (!canFindSimilar) return
findSimilar(image.id, image.folder_id); findSimilar(image.id, image.folder_id)
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
> >
@@ -162,5 +166,5 @@ export function ImageTile({
</div> </div>
</div> </div>
</div> </div>
); )
} }
+16 -16
View File
@@ -1,34 +1,34 @@
import { useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState } from 'react'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
export function TruncatedFilename({ filename }: { filename: string }) { export function TruncatedFilename({ filename }: { filename: string }) {
const textRef = useRef<HTMLParagraphElement>(null); const textRef = useRef<HTMLParagraphElement>(null)
const [isTruncated, setIsTruncated] = useState(false); const [isTruncated, setIsTruncated] = useState(false)
useLayoutEffect(() => { useLayoutEffect(() => {
const text = textRef.current; const text = textRef.current
if (!text) return; if (!text) return
const update = () => { const update = () => {
setIsTruncated(text.scrollWidth > text.clientWidth); setIsTruncated(text.scrollWidth > text.clientWidth)
}; }
update(); update()
const observer = new ResizeObserver(update); const observer = new ResizeObserver(update)
observer.observe(text); observer.observe(text)
return () => observer.disconnect(); return () => observer.disconnect()
}, [filename]); }, [filename])
const label = ( const label = (
<p ref={textRef} className="truncate text-[12px] font-medium leading-tight text-white"> <p ref={textRef} className="truncate text-[12px] leading-tight font-medium text-white">
{filename} {filename}
</p> </p>
); )
return ( return (
<Tooltip label={filename} delay={500} block followCursor disabled={!isTruncated}> <Tooltip label={filename} delay={500} block followCursor disabled={!isTruncated}>
{label} {label}
</Tooltip> </Tooltip>
); )
} }
+7 -7
View File
@@ -1,11 +1,11 @@
export function formatDuration(durationMs: number | null): string | null { export function formatDuration(durationMs: number | null): string | null {
if (!durationMs || durationMs <= 0) return null; if (!durationMs || durationMs <= 0) return null
const totalSeconds = Math.floor(durationMs / 1000); const totalSeconds = Math.floor(durationMs / 1000)
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60) % 60; const minutes = Math.floor(totalSeconds / 60) % 60
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600)
if (hours > 0) { if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
} }
return `${minutes}:${seconds.toString().padStart(2, "0")}`; return `${minutes}:${seconds.toString().padStart(2, '0')}`
} }
+31 -25
View File
@@ -6,55 +6,61 @@
*/ */
export interface IconProps { export interface IconProps {
className?: string; className?: string
strokeWidth?: number; strokeWidth?: number
} }
function strokeIcon(d: string, defaultStrokeWidth: number, displayName: string) { function strokeIcon(d: string, defaultStrokeWidth: number, displayName: string) {
function Icon({ className = "", strokeWidth = defaultStrokeWidth }: IconProps) { function Icon({ className = '', strokeWidth = defaultStrokeWidth }: IconProps) {
return ( return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d={d} /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d={d} />
</svg> </svg>
); )
} }
Icon.displayName = displayName; Icon.displayName = displayName
return Icon; return Icon
} }
export const CheckIcon = strokeIcon("M5 13l4 4L19 7", 2.5, "CheckIcon"); export const CheckIcon = strokeIcon('M5 13l4 4L19 7', 2.5, 'CheckIcon')
export const CloseIcon = strokeIcon("M6 18L18 6M6 6l12 12", 2, "CloseIcon"); export const CloseIcon = strokeIcon('M6 18L18 6M6 6l12 12', 2, 'CloseIcon')
export const ChevronDownIcon = strokeIcon("M19 9l-7 7-7-7", 2, "ChevronDownIcon"); export const ChevronDownIcon = strokeIcon('M19 9l-7 7-7-7', 2, 'ChevronDownIcon')
export const ChevronRightIcon = strokeIcon("M9 5l7 7-7 7", 2, "ChevronRightIcon"); export const ChevronRightIcon = strokeIcon('M9 5l7 7-7 7', 2, 'ChevronRightIcon')
export const PlusIcon = strokeIcon("M12 4v16m8-8H4", 1.75, "PlusIcon"); export const PlusIcon = strokeIcon('M12 4v16m8-8H4', 1.75, 'PlusIcon')
export const PhotoIcon = strokeIcon( export const PhotoIcon = strokeIcon(
"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z", 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z',
1.5, 1.5,
"PhotoIcon", 'PhotoIcon'
); )
export const FolderIcon = strokeIcon( export const FolderIcon = strokeIcon(
"M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z", 'M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z',
1.5, 1.5,
"FolderIcon", 'FolderIcon'
); )
export const WarningIcon = strokeIcon( export const WarningIcon = strokeIcon(
"M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z", 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z',
2, 2,
"WarningIcon", 'WarningIcon'
); )
export function StarIcon({ className = "" }: { className?: string }) { export function StarIcon({ className = '' }: { className?: string }) {
return ( return (
<svg className={className} fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"> <svg className={className} fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" /> <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg> </svg>
); )
} }
export function PlayIcon({ className = "" }: { className?: string }) { export function PlayIcon({ className = '' }: { className?: string }) {
return ( return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5v14l11-7z" /> <path d="M8 5v14l11-7z" />
</svg> </svg>
); )
} }
+36 -26
View File
@@ -1,26 +1,34 @@
import { useState } from "react"; import { useState } from 'react'
import { Album } from "../../store"; import { Album } from '../../store'
interface LightboxAlbumMenuProps { interface LightboxAlbumMenuProps {
imageId: number; imageId: number
albums: Album[]; albums: Album[]
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>; addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>
createAlbum: (name: string) => Promise<Album>; createAlbum: (name: string) => Promise<Album>
} }
export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }: LightboxAlbumMenuProps) { export function LightboxAlbumMenu({
const [albumMenuOpen, setAlbumMenuOpen] = useState(false); imageId,
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null); albums,
const [newAlbumName, setNewAlbumName] = useState(""); addToAlbum,
const [albumAdding, setAlbumAdding] = useState(false); createAlbum,
}: LightboxAlbumMenuProps) {
const [albumMenuOpen, setAlbumMenuOpen] = useState(false)
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null)
const [newAlbumName, setNewAlbumName] = useState('')
const [albumAdding, setAlbumAdding] = useState(false)
return ( return (
<div className="relative"> <div className="relative">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Albums</p> <p className="text-xs tracking-wider text-gray-500 uppercase">Albums</p>
<button <button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white" className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => { setAlbumMenuOpen((open) => !open); setAlbumAddedTo(null); }} onClick={() => {
setAlbumMenuOpen((open) => !open)
setAlbumAddedTo(null)
}}
> >
Add to album Add to album
</button> </button>
@@ -29,19 +37,21 @@ export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }:
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5"> <div className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5">
<div className="max-h-40 overflow-y-auto"> <div className="max-h-40 overflow-y-auto">
{albums.length === 0 ? ( {albums.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-gray-600">No albums yet create one below.</p> <p className="px-2 py-1.5 text-[11px] text-gray-600">
No albums yet create one below.
</p>
) : ( ) : (
albums.map((album) => ( albums.map((album) => (
<button <button
key={album.id} key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white" className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => { onClick={() => {
if (albumAdding) return; if (albumAdding) return
setAlbumAdding(true); setAlbumAdding(true)
void addToAlbum(album.id, [imageId]) void addToAlbum(album.id, [imageId])
.then(() => setAlbumAddedTo(album.id)) .then(() => setAlbumAddedTo(album.id))
.catch(() => undefined) .catch(() => undefined)
.finally(() => setAlbumAdding(false)); .finally(() => setAlbumAdding(false))
}} }}
disabled={albumAdding} disabled={albumAdding}
> >
@@ -58,18 +68,18 @@ export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }:
<form <form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-1.5" className="mt-1 flex gap-1 border-t border-white/[0.06] pt-1.5"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault()
const name = newAlbumName.trim(); const name = newAlbumName.trim()
if (!name || albumAdding) return; if (!name || albumAdding) return
setAlbumAdding(true); setAlbumAdding(true)
void createAlbum(name) void createAlbum(name)
.then(async (album) => { .then(async (album) => {
await addToAlbum(album.id, [imageId]); await addToAlbum(album.id, [imageId])
setAlbumAddedTo(album.id); setAlbumAddedTo(album.id)
setNewAlbumName(""); setNewAlbumName('')
}) })
.catch(() => undefined) .catch(() => undefined)
.finally(() => setAlbumAdding(false)); .finally(() => setAlbumAdding(false))
}} }}
> >
<input <input
@@ -90,5 +100,5 @@ export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }:
</div> </div>
) : null} ) : null}
</div> </div>
); )
} }
+188 -116
View File
@@ -1,44 +1,47 @@
import { MutableRefObject } from "react"; import { MutableRefObject } from 'react'
import { invoke } from "@tauri-apps/api/core"; import { invoke } from '@tauri-apps/api/core'
import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { Album, ImageExif, ImageRecord, ImageTag } from "../../store"; import { Album, ImageExif, ImageRecord, ImageTag } from '../../store'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { CloseIcon, StarIcon } from "../icons"; import { CloseIcon, StarIcon } from '../icons'
import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from "./format"; import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from './format'
import { LightboxAlbumMenu } from "./LightboxAlbumMenu"; import { LightboxAlbumMenu } from './LightboxAlbumMenu'
interface LightboxDetailsPanelProps { interface LightboxDetailsPanelProps {
selectedImage: ImageRecord; selectedImage: ImageRecord
currentIndex: number; currentIndex: number
imageCount: number; imageCount: number
imageTags: ImageTag[]; imageTags: ImageTag[]
setImageTags: React.Dispatch<React.SetStateAction<ImageTag[]>>; setImageTags: React.Dispatch<React.SetStateAction<ImageTag[]>>
imageExif: ImageExif | null; imageExif: ImageExif | null
tagInput: string; tagInput: string
setTagInput: React.Dispatch<React.SetStateAction<string>>; setTagInput: React.Dispatch<React.SetStateAction<string>>
tagAdding: boolean; tagAdding: boolean
setTagAdding: React.Dispatch<React.SetStateAction<boolean>>; setTagAdding: React.Dispatch<React.SetStateAction<boolean>>
tagsExpanded: boolean; tagsExpanded: boolean
setTagsExpanded: React.Dispatch<React.SetStateAction<boolean>>; setTagsExpanded: React.Dispatch<React.SetStateAction<boolean>>
taggingQueued: boolean; taggingQueued: boolean
setTaggingQueued: React.Dispatch<React.SetStateAction<boolean>>; setTaggingQueued: React.Dispatch<React.SetStateAction<boolean>>
currentImageIdRef: MutableRefObject<number | null>; currentImageIdRef: MutableRefObject<number | null>
albums: Album[]; albums: Album[]
canFindSimilar: boolean; canFindSimilar: boolean
canSearchRegion: boolean; canSearchRegion: boolean
regionSelectMode: boolean; regionSelectMode: boolean
regionSearching: boolean; regionSearching: boolean
taggerReady: boolean; taggerReady: boolean
taggerButtonTooltip: string; taggerButtonTooltip: string
closeImage: () => void; closeImage: () => void
findSimilar: (imageId: number, sourceFolderId: number | null) => Promise<void>; findSimilar: (imageId: number, sourceFolderId: number | null) => Promise<void>
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>; updateImageDetails: (
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>; imageId: number,
removeTag: (tagId: number) => Promise<void>; updates: { favorite?: boolean; rating?: number }
queueTaggingForImage: (imageId: number) => Promise<number>; ) => Promise<void>
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>; addUserTag: (imageId: number, tag: string) => Promise<ImageTag>
createAlbum: (name: string) => Promise<Album>; removeTag: (tagId: number) => Promise<void>
onToggleRegionMode: () => void; queueTaggingForImage: (imageId: number) => Promise<number>
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>
createAlbum: (name: string) => Promise<Album>
onToggleRegionMode: () => void
} }
export function LightboxDetailsPanel({ export function LightboxDetailsPanel({
@@ -74,7 +77,7 @@ export function LightboxDetailsPanel({
createAlbum, createAlbum,
onToggleRegionMode, onToggleRegionMode,
}: LightboxDetailsPanelProps) { }: LightboxDetailsPanelProps) {
const aiRating = selectedImage.ai_rating ? ratingPill(selectedImage.ai_rating) : null; const aiRating = selectedImage.ai_rating ? ratingPill(selectedImage.ai_rating) : null
const hasCameraInfo = const hasCameraInfo =
imageExif && imageExif &&
(imageExif.make || (imageExif.make ||
@@ -84,7 +87,7 @@ export function LightboxDetailsPanel({
imageExif.exposure_time || imageExif.exposure_time ||
imageExif.iso || imageExif.iso ||
imageExif.focal_length || imageExif.focal_length ||
(imageExif.gps_lat != null && imageExif.gps_lon != null)); (imageExif.gps_lat != null && imageExif.gps_lon != null))
return ( return (
<div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72"> <div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72">
@@ -94,34 +97,56 @@ export function LightboxDetailsPanel({
<p className="text-xs text-gray-500">Details</p> <p className="text-xs text-gray-500">Details</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip label={selectedImage.favorite ? "Remove favorite" : "Add favorite"} followCursor> <Tooltip label={selectedImage.favorite ? 'Remove favorite' : 'Add favorite'} followCursor>
<button <button
className={`rounded-full border p-2 ${selectedImage.favorite ? "border-rose-400/40 bg-rose-500/10 text-rose-300" : "border-white/10 bg-white/5 text-gray-400 hover:text-white"}`} className={`rounded-full border p-2 ${selectedImage.favorite ? 'border-rose-400/40 bg-rose-500/10 text-rose-300' : 'border-white/10 bg-white/5 text-gray-400 hover:text-white'}`}
onClick={() => void updateImageDetails(selectedImage.id, { favorite: !selectedImage.favorite })} onClick={() =>
void updateImageDetails(selectedImage.id, { favorite: !selectedImage.favorite })
}
> >
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" /> <path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label={canFindSimilar ? "Find similar images" : "Embeddings not ready"} followCursor> <Tooltip
label={canFindSimilar ? 'Find similar images' : 'Embeddings not ready'}
followCursor
>
<button <button
className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${ className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${
canFindSimilar canFindSimilar
? "border-white/10 bg-white/5 text-gray-300 hover:text-white" ? 'border-white/10 bg-white/5 text-gray-300 hover:text-white'
: "border-white/5 bg-white/[0.03] text-gray-600 cursor-not-allowed" : 'cursor-not-allowed border-white/5 bg-white/[0.03] text-gray-600'
}`} }`}
onClick={() => { onClick={() => {
if (!canFindSimilar) return; if (!canFindSimilar) return
void findSimilar(selectedImage.id, selectedImage.folder_id); void findSimilar(selectedImage.id, selectedImage.folder_id)
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
> >
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M13 3l1.55 4.65L19 9.2l-4.45 1.55L13 15.4l-1.55-4.65L7 9.2l4.45-1.55L13 3z" /> className="h-4 w-4 shrink-0"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M5.5 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2L2.5 17.5l2.2-.8.8-2.2z" /> fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.6}
d="M13 3l1.55 4.65L19 9.2l-4.45 1.55L13 15.4l-1.55-4.65L7 9.2l4.45-1.55L13 3z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.6}
d="M5.5 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2L2.5 17.5l2.2-.8.8-2.2z"
/>
</svg> </svg>
<span className="hidden lg:inline">{canFindSimilar ? "Similar" : "Embeddings not ready"}</span> <span className="hidden lg:inline">
{canFindSimilar ? 'Similar' : 'Embeddings not ready'}
</span>
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
@@ -132,25 +157,39 @@ export function LightboxDetailsPanel({
{canSearchRegion && ( {canSearchRegion && (
<div className="shrink-0 px-5 pb-3"> <div className="shrink-0 px-5 pb-3">
<Tooltip label={regionSelectMode ? "Cancel region selection" : "Draw a region on the image to search for similar content"} followCursor> <Tooltip
label={
regionSelectMode
? 'Cancel region selection'
: 'Draw a region on the image to search for similar content'
}
followCursor
>
<button <button
className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${ className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${
regionSelectMode regionSelectMode
? "border-violet-400/40 bg-violet-500/15 text-violet-300 hover:bg-violet-500/20" ? 'border-violet-400/40 bg-violet-500/15 text-violet-300 hover:bg-violet-500/20'
: regionSearching : regionSearching
? "border-white/5 bg-white/[0.03] text-gray-500 cursor-not-allowed" ? 'cursor-not-allowed border-white/5 bg-white/[0.03] text-gray-500'
: "border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white" : 'border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white'
}`} }`}
onClick={() => { onClick={() => {
if (regionSearching) return; if (regionSearching) return
onToggleRegionMode(); onToggleRegionMode()
}} }}
disabled={regionSearching} disabled={regionSearching}
> >
{regionSearching ? ( {regionSearching ? (
<span className="flex items-center justify-center gap-1.5"> <span className="flex items-center justify-center gap-1.5">
<svg className="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24"> <svg className="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg> </svg>
Searching region Searching region
@@ -163,7 +202,12 @@ export function LightboxDetailsPanel({
) : ( ) : (
<span className="flex items-center justify-center gap-1.5"> <span className="flex items-center justify-center gap-1.5">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg> </svg>
Search within image Search within image
</span> </span>
@@ -173,23 +217,30 @@ export function LightboxDetailsPanel({
</div> </div>
)} )}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm"> <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 pb-4 text-sm">
<div className="grid grid-cols-2 gap-x-6 gap-y-4"> <div className="grid grid-cols-2 gap-x-6 gap-y-4">
<div className="col-span-2"> <div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Rating</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, index) => { {Array.from({ length: 5 }, (_, index) => {
const rating = index + 1; const rating = index + 1
return ( return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor delay={750}> <Tooltip
key={rating}
label={`Set ${rating} star rating`}
followCursor
delay={750}
>
<button <button
className="rounded-md p-1" className="rounded-md p-1"
onClick={() => void updateImageDetails(selectedImage.id, { rating })} onClick={() => void updateImageDetails(selectedImage.id, { rating })}
> >
<StarIcon className={`h-5 w-5 ${rating <= selectedImage.rating ? "text-amber-300" : "text-white/20 hover:text-white/50"}`} /> <StarIcon
className={`h-5 w-5 ${rating <= selectedImage.rating ? 'text-amber-300' : 'text-white/20 hover:text-white/50'}`}
/>
</button> </button>
</Tooltip> </Tooltip>
); )
})} })}
{selectedImage.rating > 0 ? ( {selectedImage.rating > 0 ? (
<Tooltip label="Remove rating" followCursor> <Tooltip label="Remove rating" followCursor>
@@ -205,31 +256,31 @@ export function LightboxDetailsPanel({
</div> </div>
<div> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Dimensions</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Dimensions</p>
<p className="text-white"> <p className="text-white">
{selectedImage.width && selectedImage.height {selectedImage.width && selectedImage.height
? `${selectedImage.width} x ${selectedImage.height}px` ? `${selectedImage.width} x ${selectedImage.height}px`
: "Pending / unavailable"} : 'Pending / unavailable'}
</p> </p>
</div> </div>
{selectedImage.media_kind === "video" ? ( {selectedImage.media_kind === 'video' ? (
<> <>
<div> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Duration</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Duration</p>
<p className="text-white">{formatDuration(selectedImage.duration_ms)}</p> <p className="text-white">{formatDuration(selectedImage.duration_ms)}</p>
</div> </div>
<div> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Video codec</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Video codec</p>
<p className="text-white">{selectedImage.video_codec ?? "Pending / unavailable"}</p> <p className="text-white">{selectedImage.video_codec ?? 'Pending / unavailable'}</p>
</div> </div>
<div> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Audio codec</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Audio codec</p>
<p className="text-white">{selectedImage.audio_codec ?? "None / unavailable"}</p> <p className="text-white">{selectedImage.audio_codec ?? 'None / unavailable'}</p>
</div> </div>
{selectedImage.metadata_error ? ( {selectedImage.metadata_error ? (
<div className="col-span-2"> <div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Metadata</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Metadata</p>
<p className="text-amber-300">{selectedImage.metadata_error}</p> <p className="text-amber-300">{selectedImage.metadata_error}</p>
</div> </div>
) : null} ) : null}
@@ -237,20 +288,22 @@ export function LightboxDetailsPanel({
) : null} ) : null}
<div> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Type</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Type</p>
<p className="text-white">{selectedImage.mime_type}</p> <p className="text-white">{selectedImage.mime_type}</p>
</div> </div>
<div> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">File size</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">File size</p>
<p className="text-white">{formatBytes(selectedImage.file_size)}</p> <p className="text-white">{formatBytes(selectedImage.file_size)}</p>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Modified</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Modified</p>
<p className="text-white">{formatDate(selectedImage.modified_at)}</p> <p className="text-white">{formatDate(selectedImage.modified_at)}</p>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Embedding</p> <p className="mb-1 text-xs tracking-wider text-gray-500 uppercase">Embedding</p>
<p className="text-white">{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}</p> <p className="text-white">
{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}
</p>
{selectedImage.embedding_error ? ( {selectedImage.embedding_error ? (
<p className="mt-1 text-xs text-amber-300">{selectedImage.embedding_error}</p> <p className="mt-1 text-xs text-amber-300">{selectedImage.embedding_error}</p>
) : null} ) : null}
@@ -259,24 +312,26 @@ export function LightboxDetailsPanel({
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Tags</p> <p className="text-xs tracking-wider text-gray-500 uppercase">Tags</p>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{aiRating ? ( {aiRating ? (
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${aiRating.className}`}> <span
className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${aiRating.className}`}
>
{aiRating.label} {aiRating.label}
</span> </span>
) : null} ) : null}
{selectedImage.media_kind === "image" ? ( {selectedImage.media_kind === 'image' ? (
<Tooltip label={taggerButtonTooltip} followCursor> <Tooltip label={taggerButtonTooltip} followCursor>
<button <button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40" className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!taggerReady || taggingQueued} disabled={!taggerReady || taggingQueued}
onClick={() => { onClick={() => {
setTaggingQueued(true); setTaggingQueued(true)
void queueTaggingForImage(selectedImage.id).catch(() => undefined); void queueTaggingForImage(selectedImage.id).catch(() => undefined)
}} }}
> >
{taggingQueued ? "Queued" : "AI tags"} {taggingQueued ? 'Queued' : 'AI tags'}
</button> </button>
</Tooltip> </Tooltip>
) : null} ) : null}
@@ -289,25 +344,29 @@ export function LightboxDetailsPanel({
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((tag) => ( {(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((tag) => (
<Tooltip <Tooltip
key={tag.id} key={tag.id}
label={tag.source === "ai" && tag.confidence !== null ? `AI confidence: ${(tag.confidence * 100).toFixed(0)}%` : ""} label={
tag.source === 'ai' && tag.confidence !== null
? `AI confidence: ${(tag.confidence * 100).toFixed(0)}%`
: ''
}
followCursor followCursor
disabled={tag.source !== "ai" || tag.confidence === null} disabled={tag.source !== 'ai' || tag.confidence === null}
> >
<span <span
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${ className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
tag.source === "ai" tag.source === 'ai'
? "border-sky-400/20 bg-sky-500/8 text-sky-300" ? 'border-sky-400/20 bg-sky-500/8 text-sky-300'
: "border-white/10 bg-white/5 text-gray-300" : 'border-white/10 bg-white/5 text-gray-300'
}`} }`}
> >
{tag.tag} {tag.tag}
<Tooltip label="Remove tag" followCursor> <Tooltip label="Remove tag" followCursor>
<button <button
className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100" className="text-gray-600 opacity-0 transition-opacity group-hover:opacity-100 hover:text-white"
onClick={() => { onClick={() => {
void removeTag(tag.id).then(() => void removeTag(tag.id).then(() =>
setImageTags((prev) => prev.filter((item) => item.id !== tag.id)), setImageTags((prev) => prev.filter((item) => item.id !== tag.id))
); )
}} }}
> >
<CloseIcon className="h-3 w-3" /> <CloseIcon className="h-3 w-3" />
@@ -319,10 +378,10 @@ export function LightboxDetailsPanel({
</div> </div>
{imageTags.length > 8 && ( {imageTags.length > 8 && (
<button <button
className="mt-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors" className="mt-1.5 text-xs text-gray-500 transition-colors hover:text-gray-300"
onClick={() => setTagsExpanded((expanded) => !expanded)} onClick={() => setTagsExpanded((expanded) => !expanded)}
> >
{tagsExpanded ? "Show less" : `+${imageTags.length - 8} more`} {tagsExpanded ? 'Show less' : `+${imageTags.length - 8} more`}
</button> </button>
)} )}
</> </>
@@ -333,19 +392,19 @@ export function LightboxDetailsPanel({
<form <form
className="mt-2 flex gap-1.5" className="mt-2 flex gap-1.5"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault()
const raw = tagInput.trim(); const raw = tagInput.trim()
if (!raw || tagAdding) return; if (!raw || tagAdding) return
setTagAdding(true); setTagAdding(true)
const taggedImageId = selectedImage.id; const taggedImageId = selectedImage.id
void addUserTag(taggedImageId, raw) void addUserTag(taggedImageId, raw)
.then((newTag) => { .then((newTag) => {
if (currentImageIdRef.current !== taggedImageId) return; if (currentImageIdRef.current !== taggedImageId) return
setImageTags((prev) => [...prev, newTag]); setImageTags((prev) => [...prev, newTag])
setTagInput(""); setTagInput('')
}) })
.catch(() => undefined) .catch(() => undefined)
.finally(() => setTagAdding(false)); .finally(() => setTagAdding(false))
}} }}
> >
<input <input
@@ -374,15 +433,18 @@ export function LightboxDetailsPanel({
{hasCameraInfo ? ( {hasCameraInfo ? (
<div> <div>
<p className="mb-2 text-xs uppercase tracking-wider text-gray-500">Camera</p> <p className="mb-2 text-xs tracking-wider text-gray-500 uppercase">Camera</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{imageExif.make || imageExif.model ? ( {imageExif.make || imageExif.model ? (
<p className="text-sm text-white"> <p className="text-sm text-white">
{[imageExif.make, imageExif.model].filter(Boolean).join(" ")} {[imageExif.make, imageExif.model].filter(Boolean).join(' ')}
</p> </p>
) : null} ) : null}
{imageExif.lens ? <p className="text-xs text-gray-400">{imageExif.lens}</p> : null} {imageExif.lens ? <p className="text-xs text-gray-400">{imageExif.lens}</p> : null}
{imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? ( {imageExif.f_number ||
imageExif.exposure_time ||
imageExif.iso ||
imageExif.focal_length ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-400"> <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-400">
{imageExif.f_number ? <span>{imageExif.f_number}</span> : null} {imageExif.f_number ? <span>{imageExif.f_number}</span> : null}
{imageExif.exposure_time ? <span>{imageExif.exposure_time}</span> : null} {imageExif.exposure_time ? <span>{imageExif.exposure_time}</span> : null}
@@ -395,14 +457,19 @@ export function LightboxDetailsPanel({
<button <button
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300" className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
onClick={() => onClick={() =>
void invoke("open_map_location", { void invoke('open_map_location', {
params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon }, params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon },
}) })
} }
> >
{imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)} {imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)}
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>
@@ -413,25 +480,30 @@ export function LightboxDetailsPanel({
<div> <div>
<div className="mb-1 flex items-center justify-between"> <div className="mb-1 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Path</p> <p className="text-xs tracking-wider text-gray-500 uppercase">Path</p>
<Tooltip label="Reveal in Explorer" anchorToCursor> <Tooltip label="Reveal in Explorer" anchorToCursor>
<button <button
className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300" className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300"
onClick={() => void revealItemInDir(selectedImage.path)} onClick={() => void revealItemInDir(selectedImage.path)}
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
/>
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p> <p className="text-xs break-all text-gray-400">{selectedImage.path}</p>
</div> </div>
</div> </div>
<div className="shrink-0 mt-auto px-5 pb-4 pt-2 text-center text-xs text-gray-600"> <div className="mt-auto shrink-0 px-5 pt-2 pb-4 text-center text-xs text-gray-600">
{currentIndex + 1} / {imageCount} {currentIndex + 1} / {imageCount}
</div> </div>
</div> </div>
); )
} }
+10 -10
View File
@@ -1,25 +1,25 @@
import { ChevronRightIcon } from "../icons"; import { ChevronRightIcon } from '../icons'
interface LightboxNavButtonProps { interface LightboxNavButtonProps {
direction: "previous" | "next"; direction: 'previous' | 'next'
disabled: boolean; disabled: boolean
onClick: () => void; onClick: () => void
} }
export function LightboxNavButton({ direction, disabled, onClick }: LightboxNavButtonProps) { export function LightboxNavButton({ direction, disabled, onClick }: LightboxNavButtonProps) {
const isNext = direction === "next"; const isNext = direction === 'next'
return ( return (
<button <button
className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20 ${ className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20 ${
isNext ? "right-72 lg:right-80" : "left-4" isNext ? 'right-72 lg:right-80' : 'left-4'
}`} }`}
disabled={disabled} disabled={disabled}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onClick(); onClick()
}} }}
aria-label={isNext ? "Next image" : "Previous image"} aria-label={isNext ? 'Next image' : 'Previous image'}
> >
{isNext ? ( {isNext ? (
<ChevronRightIcon className="h-5 w-5" /> <ChevronRightIcon className="h-5 w-5" />
@@ -29,5 +29,5 @@ export function LightboxNavButton({ direction, disabled, onClick }: LightboxNavB
</svg> </svg>
)} )}
</button> </button>
); )
} }
+87 -51
View File
@@ -1,28 +1,28 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { RefObject } from "react"; import { RefObject } from 'react'
import { ImageRecord } from "../../store"; import { ImageRecord } from '../../store'
import { mediaSrc } from "../../lib/mediaSrc"; import { mediaSrc } from '../../lib/mediaSrc'
import { VideoPlayer } from "../VideoPlayer"; import { VideoPlayer } from '../VideoPlayer'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { SelectionOverlay, ViewTransform } from "./types"; import { SelectionOverlay, ViewTransform } from './types'
import { MAX_ZOOM, MIN_ZOOM, zoomViewAt } from "./viewTransform"; import { MAX_ZOOM, MIN_ZOOM, zoomViewAt } from './viewTransform'
interface LightboxViewportProps { interface LightboxViewportProps {
selectedImage: ImageRecord; selectedImage: ImageRecord
imageViewportRef: RefObject<HTMLDivElement | null>; imageViewportRef: RefObject<HTMLDivElement | null>
imgRef: RefObject<HTMLImageElement | null>; imgRef: RefObject<HTMLImageElement | null>
view: ViewTransform; view: ViewTransform
zoom: number; zoom: number
regionSelectMode: boolean; regionSelectMode: boolean
isPanning: boolean; isPanning: boolean
selectionOverlay: SelectionOverlay | null; selectionOverlay: SelectionOverlay | null
canStartSlideshow: boolean; canStartSlideshow: boolean
onStartSlideshow: () => void; onStartSlideshow: () => void
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void; onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void; onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void; onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void
setView: React.Dispatch<React.SetStateAction<ViewTransform>>; setView: React.Dispatch<React.SetStateAction<ViewTransform>>
clampPan: (view: ViewTransform) => ViewTransform; clampPan: (view: ViewTransform) => ViewTransform
} }
export function LightboxViewport({ export function LightboxViewport({
@@ -47,12 +47,12 @@ export function LightboxViewport({
ref={imageViewportRef} ref={imageViewportRef}
className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${ className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${
regionSelectMode regionSelectMode
? "cursor-crosshair select-none" ? 'cursor-crosshair select-none'
: isPanning : isPanning
? "cursor-grabbing select-none" ? 'cursor-grabbing select-none'
: zoom > 1 && selectedImage.media_kind === "image" : zoom > 1 && selectedImage.media_kind === 'image'
? "cursor-grab" ? 'cursor-grab'
: "" : ''
}`} }`}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
@@ -61,10 +61,24 @@ export function LightboxViewport({
{regionSelectMode && ( {regionSelectMode && (
<div className="pointer-events-none absolute inset-x-0 top-4 z-20 flex justify-center"> <div className="pointer-events-none absolute inset-x-0 top-4 z-20 flex justify-center">
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-xs text-gray-300 backdrop-blur"> <div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-xs text-gray-300 backdrop-blur">
<svg className="h-3.5 w-3.5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" /> className="h-3.5 w-3.5 text-violet-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg> </svg>
Draw a region to search <kbd className="ml-1 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd> to cancel Draw a region to search {' '}
<kbd className="ml-1 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]">
Esc
</kbd>{' '}
to cancel
</div> </div>
</div> </div>
)} )}
@@ -85,28 +99,28 @@ export function LightboxViewport({
<motion.div <motion.div
key={selectedImage.id} key={selectedImage.id}
className={ className={
selectedImage.media_kind === "video" selectedImage.media_kind === 'video'
? "absolute inset-0" ? 'absolute inset-0'
: "flex items-center justify-center" : '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' ? (
<VideoPlayer src={mediaSrc(selectedImage.path) ?? ""} /> <VideoPlayer src={mediaSrc(selectedImage.path) ?? ''} />
) : ( ) : (
<img <img
ref={imgRef} ref={imgRef}
src={mediaSrc(selectedImage.path) ?? ""} src={mediaSrc(selectedImage.path) ?? ''}
alt={selectedImage.filename} alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl" className="max-w-full rounded-2xl shadow-2xl"
draggable={false} draggable={false}
style={{ style={{
maxHeight: "calc(100vh - 10rem)", maxHeight: 'calc(100vh - 10rem)',
transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`, transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`,
transformOrigin: "center center", transformOrigin: 'center center',
...(regionSelectMode ? { opacity: 0.85 } : {}), ...(regionSelectMode ? { opacity: 0.85 } : {}),
}} }}
/> />
@@ -115,42 +129,64 @@ export function LightboxViewport({
</AnimatePresence> </AnimatePresence>
{!regionSelectMode && ( {!regionSelectMode && (
<div className="pointer-events-none absolute right-6 top-6 opacity-75 transition-opacity group-hover:opacity-100"> <div className="pointer-events-none absolute top-6 right-6 opacity-75 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 p-1 shadow-2xl shadow-black/25 backdrop-blur"> <div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 p-1 shadow-2xl shadow-black/25 backdrop-blur">
<Tooltip label={canStartSlideshow ? "Start slideshow" : "No images available for slideshow"} followCursor> <Tooltip
label={canStartSlideshow ? 'Start slideshow' : 'No images available for slideshow'}
followCursor
>
<button <button
aria-label="Start slideshow" aria-label="Start slideshow"
className={`rounded-full p-2 transition-colors ${ className={`rounded-full p-2 transition-colors ${
canStartSlideshow canStartSlideshow
? "text-gray-300 hover:bg-white/10 hover:text-white" ? 'text-gray-300 hover:bg-white/10 hover:text-white'
: "cursor-not-allowed text-gray-600" : 'cursor-not-allowed text-gray-600'
}`} }`}
disabled={!canStartSlideshow} disabled={!canStartSlideshow}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onStartSlideshow(); onStartSlideshow()
}} }}
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 5.5v13l10-6.5-10-6.5z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M8 5.5v13l10-6.5-10-6.5z"
/>
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>
{selectedImage.media_kind === "image" ? ( {selectedImage.media_kind === 'image' ? (
<> <>
<div className="mx-0.5 h-5 w-px bg-white/10" /> <div className="mx-0.5 h-5 w-px bg-white/10" />
<button <button
aria-label="Zoom out" aria-label="Zoom out"
className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white" className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setView((currentView) => clampPan(zoomViewAt(currentView, Math.max(MIN_ZOOM, currentView.zoom - 0.25), 0, 0)))} onClick={() =>
setView((currentView) =>
clampPan(
zoomViewAt(currentView, Math.max(MIN_ZOOM, currentView.zoom - 0.25), 0, 0)
)
)
}
> >
- -
</button> </button>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span> <span className="min-w-14 text-center text-xs text-gray-300">
{Math.round(zoom * 100)}%
</span>
<button <button
aria-label="Zoom in" aria-label="Zoom in"
className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white" className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setView((currentView) => clampPan(zoomViewAt(currentView, Math.min(MAX_ZOOM, currentView.zoom + 0.25), 0, 0)))} onClick={() =>
setView((currentView) =>
clampPan(
zoomViewAt(currentView, Math.min(MAX_ZOOM, currentView.zoom + 0.25), 0, 0)
)
)
}
> >
+ +
</button> </button>
@@ -160,5 +196,5 @@ export function LightboxViewport({
</div> </div>
)} )}
</div> </div>
); )
} }
+46 -39
View File
@@ -1,23 +1,23 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { ImageRecord } from "../../store"; import { ImageRecord } from '../../store'
import { mediaSrc } from "../../lib/mediaSrc"; import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { ChevronRightIcon, CloseIcon } from "../icons"; import { ChevronRightIcon, CloseIcon } from '../icons'
import { SlideshowMotion } from "./useSlideshow"; import { SlideshowMotion } from './useSlideshow'
interface SlideshowViewProps { interface SlideshowViewProps {
selectedImage: ImageRecord; selectedImage: ImageRecord
imageCount: number; imageCount: number
position: number; position: number
controlsShown: boolean; controlsShown: boolean
paused: boolean; paused: boolean
loadingMore: boolean; loadingMore: boolean
motionConfig: SlideshowMotion; motionConfig: SlideshowMotion
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void; onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void
onShowControls: () => void; onShowControls: () => void
onExit: () => void; onExit: () => void
onGo: (direction: -1 | 1) => void; onGo: (direction: -1 | 1) => void
onTogglePaused: () => void; onTogglePaused: () => void
} }
export function SlideshowView({ export function SlideshowView({
@@ -37,16 +37,16 @@ export function SlideshowView({
return ( return (
<div <div
className={`relative flex h-full w-full items-center justify-center overflow-hidden bg-black ${ className={`relative flex h-full w-full items-center justify-center overflow-hidden bg-black ${
controlsShown ? "" : "cursor-none" controlsShown ? '' : 'cursor-none'
}`} }`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onShowControls(); onShowControls()
}} }}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
> >
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{selectedImage.media_kind === "image" ? ( {selectedImage.media_kind === 'image' ? (
<motion.div <motion.div
key={selectedImage.id} key={selectedImage.id}
className="absolute inset-0 flex items-center justify-center" className="absolute inset-0 flex items-center justify-center"
@@ -56,7 +56,7 @@ export function SlideshowView({
transition={motionConfig.imageTransition} transition={motionConfig.imageTransition}
> >
<motion.img <motion.img
src={mediaSrc(selectedImage.path) ?? ""} src={mediaSrc(selectedImage.path) ?? ''}
alt={selectedImage.filename} alt={selectedImage.filename}
className="max-h-full max-w-full object-contain" className="max-h-full max-w-full object-contain"
draggable={false} draggable={false}
@@ -84,20 +84,22 @@ export function SlideshowView({
animate={{ opacity: controlsShown ? 1 : 0, y: controlsShown ? 0 : -6 }} animate={{ opacity: controlsShown ? 1 : 0, y: controlsShown ? 0 : -6 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
> >
<div className="max-w-[60vw] whitespace-nowrap rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md"> <div className="max-w-[60vw] rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs whitespace-nowrap text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md">
<span className="font-medium text-white">{position}</span> <span className="font-medium text-white">{position}</span>
<span className="mx-1 text-gray-600">/</span> <span className="mx-1 text-gray-600">/</span>
<span>{imageCount}</span> <span>{imageCount}</span>
<span className="mx-2 text-gray-700"></span> <span className="mx-2 text-gray-700"></span>
<span className="inline-block max-w-[42vw] truncate align-bottom text-gray-400">{selectedImage.filename}</span> <span className="inline-block max-w-[42vw] truncate align-bottom text-gray-400">
{selectedImage.filename}
</span>
</div> </div>
<Tooltip label="Exit slideshow" followCursor> <Tooltip label="Exit slideshow" followCursor>
<button <button
aria-label="Exit slideshow" aria-label="Exit slideshow"
className="pointer-events-auto rounded-full border border-white/10 bg-black/45 p-2 text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md transition-colors hover:bg-white/10 hover:text-white" className="pointer-events-auto rounded-full border border-white/10 bg-black/45 p-2 text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md transition-colors hover:bg-white/10 hover:text-white"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onExit(); onExit()
}} }}
> >
<CloseIcon className="h-4 w-4" strokeWidth={1.8} /> <CloseIcon className="h-4 w-4" strokeWidth={1.8} />
@@ -118,23 +120,28 @@ export function SlideshowView({
className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35" className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={imageCount <= 1} disabled={imageCount <= 1}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onGo(-1); onGo(-1)
}} }}
> >
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label={paused ? "Resume slideshow" : "Pause slideshow"} followCursor> <Tooltip label={paused ? 'Resume slideshow' : 'Pause slideshow'} followCursor>
<button <button
aria-label={paused ? "Resume slideshow" : "Pause slideshow"} aria-label={paused ? 'Resume slideshow' : 'Pause slideshow'}
className="rounded-full border border-white/10 bg-white/10 p-2.5 text-white transition-colors hover:bg-white/15" className="rounded-full border border-white/10 bg-white/10 p-2.5 text-white transition-colors hover:bg-white/15"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onTogglePaused(); onTogglePaused()
onShowControls(); onShowControls()
}} }}
> >
{paused ? ( {paused ? (
@@ -154,8 +161,8 @@ export function SlideshowView({
className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35" className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={imageCount <= 1} disabled={imageCount <= 1}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation()
onGo(1); onGo(1)
}} }}
> >
<ChevronRightIcon className="h-5 w-5" strokeWidth={1.8} /> <ChevronRightIcon className="h-5 w-5" strokeWidth={1.8} />
@@ -165,10 +172,10 @@ export function SlideshowView({
</motion.div> </motion.div>
{loadingMore ? ( {loadingMore ? (
<div className="pointer-events-none absolute bottom-6 right-6 rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs text-gray-500 backdrop-blur-md"> <div className="pointer-events-none absolute right-6 bottom-6 rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs text-gray-500 backdrop-blur-md">
Loading more Loading more
</div> </div>
) : null} ) : null}
</div> </div>
); )
} }
+37 -31
View File
@@ -1,56 +1,62 @@
import { AiRating } from "../../store"; import { AiRating } from '../../store'
export function formatBytes(bytes: number): string { export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
} }
export function formatDate(iso: string | null): string { export function formatDate(iso: string | null): string {
if (!iso) return "Unknown"; if (!iso) return 'Unknown'
return new Date(iso).toLocaleDateString(undefined, { return new Date(iso).toLocaleDateString(undefined, {
year: "numeric", year: 'numeric',
month: "long", month: 'long',
day: "numeric", day: 'numeric',
}); })
} }
export function formatDuration(durationMs: number | null): string { export function formatDuration(durationMs: number | null): string {
if (!durationMs || durationMs <= 0) return "Pending / unavailable"; if (!durationMs || durationMs <= 0) return 'Pending / unavailable'
const totalSeconds = Math.floor(durationMs / 1000); const totalSeconds = Math.floor(durationMs / 1000)
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60) % 60; const minutes = Math.floor(totalSeconds / 60) % 60
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600)
if (hours > 0) { if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
} }
return `${minutes}:${seconds.toString().padStart(2, "0")}`; return `${minutes}:${seconds.toString().padStart(2, '0')}`
} }
export function embeddingLabel(status: string, model: string | null): string { export function embeddingLabel(status: string, model: string | null): string {
if (status === "ready") { if (status === 'ready') {
return model ? `Ready (${model})` : "Ready"; return model ? `Ready (${model})` : 'Ready'
} }
if (status === "failed") { if (status === 'failed') {
return "Failed"; return 'Failed'
} }
if (status === "processing") { if (status === 'processing') {
return "Processing"; return 'Processing'
} }
return "Queued"; return 'Queued'
} }
export function ratingPill(rating: AiRating): { label: string; className: string } { export function ratingPill(rating: AiRating): { label: string; className: string } {
switch (rating) { switch (rating) {
case "general": case 'general':
return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" }; return {
case "sensitive": label: 'General',
return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-300" }; className: 'border-emerald-400/25 bg-emerald-500/10 text-emerald-300',
case "questionable": }
return { label: "Questionable", className: "border-amber-400/25 bg-amber-500/10 text-amber-300" }; case 'sensitive':
case "explicit": return { label: 'Sensitive', className: 'border-sky-400/25 bg-sky-500/10 text-sky-300' }
return { label: "Explicit", className: "border-red-400/25 bg-red-500/10 text-red-300" }; case 'questionable':
return {
label: 'Questionable',
className: 'border-amber-400/25 bg-amber-500/10 text-amber-300',
}
case 'explicit':
return { label: 'Explicit', className: 'border-red-400/25 bg-red-500/10 text-red-300' }
} }
} }
+15 -15
View File
@@ -1,26 +1,26 @@
export interface DragRect { export interface DragRect {
startX: number; startX: number
startY: number; startY: number
endX: number; endX: number
endY: number; endY: number
} }
export interface ViewTransform { export interface ViewTransform {
zoom: number; zoom: number
panX: number; panX: number
panY: number; panY: number
} }
export interface NormalizedCrop { export interface NormalizedCrop {
x: number; x: number
y: number; y: number
w: number; w: number
h: number; h: number
} }
export interface SelectionOverlay { export interface SelectionOverlay {
left: number; left: number
top: number; top: number
width: number; width: number
height: number; height: number
} }
@@ -1,13 +1,13 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react'
import { ImageExif, ImageRecord, ImageTag, TaggerModelStatus } from "../../store"; import { ImageExif, ImageRecord, ImageTag, TaggerModelStatus } from '../../store'
interface UseLightboxMediaDetailsParams { interface UseLightboxMediaDetailsParams {
selectedImage: ImageRecord | null; selectedImage: ImageRecord | null
taggerModelStatus: TaggerModelStatus | null; taggerModelStatus: TaggerModelStatus | null
getImageTags: (imageId: number) => Promise<ImageTag[]>; getImageTags: (imageId: number) => Promise<ImageTag[]>
getImageExif: (imageId: number) => Promise<ImageExif>; getImageExif: (imageId: number) => Promise<ImageExif>
loadTaggerModelStatus: () => Promise<void>; loadTaggerModelStatus: () => Promise<void>
onSelectedImageReset: () => void; onSelectedImageReset: () => void
} }
export function useLightboxMediaDetails({ export function useLightboxMediaDetails({
@@ -18,54 +18,66 @@ export function useLightboxMediaDetails({
loadTaggerModelStatus, loadTaggerModelStatus,
onSelectedImageReset, onSelectedImageReset,
}: UseLightboxMediaDetailsParams) { }: UseLightboxMediaDetailsParams) {
const [imageTags, setImageTags] = useState<ImageTag[]>([]); const [imageTags, setImageTags] = useState<ImageTag[]>([])
const [imageExif, setImageExif] = useState<ImageExif | null>(null); const [imageExif, setImageExif] = useState<ImageExif | null>(null)
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState('')
const [tagAdding, setTagAdding] = useState(false); const [tagAdding, setTagAdding] = useState(false)
const [tagsExpanded, setTagsExpanded] = useState(false); const [tagsExpanded, setTagsExpanded] = useState(false)
const [taggingQueued, setTaggingQueued] = useState(false); const [taggingQueued, setTaggingQueued] = useState(false)
const currentImageIdRef = useRef<number | null>(null); const currentImageIdRef = useRef<number | null>(null)
currentImageIdRef.current = selectedImage?.id ?? null; currentImageIdRef.current = selectedImage?.id ?? null
useEffect(() => { useEffect(() => {
setImageTags([]); setImageTags([])
setImageExif(null); setImageExif(null)
setTagInput(""); setTagInput('')
setTagsExpanded(false); setTagsExpanded(false)
setTaggingQueued(false); setTaggingQueued(false)
onSelectedImageReset(); onSelectedImageReset()
}, [onSelectedImageReset, selectedImage?.id]); }, [onSelectedImageReset, selectedImage?.id])
useEffect(() => { useEffect(() => {
if (!selectedImage) return; if (!selectedImage) return
let cancelled = false; let cancelled = false
void getImageTags(selectedImage.id) void getImageTags(selectedImage.id)
.then((tags) => { if (!cancelled) setImageTags(tags); }) .then((tags) => {
.catch(() => { if (!cancelled) setImageTags([]); }); if (!cancelled) setImageTags(tags)
return () => { cancelled = true; }; })
}, [getImageTags, selectedImage?.ai_tagged_at, selectedImage?.id]); .catch(() => {
if (!cancelled) setImageTags([])
useEffect(() => { })
if (!selectedImage || selectedImage.media_kind !== "image") { return () => {
setImageExif(null); cancelled = true
return;
} }
let cancelled = false; }, [getImageTags, selectedImage?.ai_tagged_at, selectedImage?.id])
useEffect(() => {
if (!selectedImage || selectedImage.media_kind !== 'image') {
setImageExif(null)
return
}
let cancelled = false
void getImageExif(selectedImage.id) void getImageExif(selectedImage.id)
.then((exif) => { if (!cancelled) setImageExif(exif); }) .then((exif) => {
.catch(() => { if (!cancelled) setImageExif(null); }); if (!cancelled) setImageExif(exif)
return () => { cancelled = true; }; })
}, [getImageExif, selectedImage?.id, selectedImage?.media_kind]); .catch(() => {
if (!cancelled) setImageExif(null)
})
return () => {
cancelled = true
}
}, [getImageExif, selectedImage?.id, selectedImage?.media_kind])
useEffect(() => { useEffect(() => {
if (selectedImage?.media_kind !== "image" || taggerModelStatus !== null) return; if (selectedImage?.media_kind !== 'image' || taggerModelStatus !== null) return
void loadTaggerModelStatus(); void loadTaggerModelStatus()
}, [loadTaggerModelStatus, selectedImage?.media_kind, taggerModelStatus]); }, [loadTaggerModelStatus, selectedImage?.media_kind, taggerModelStatus])
useEffect(() => { useEffect(() => {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false); if (selectedImage?.ai_tagged_at) setTaggingQueued(false)
}, [selectedImage?.ai_tagged_at]); }, [selectedImage?.ai_tagged_at])
return { return {
currentImageIdRef, currentImageIdRef,
@@ -80,5 +92,5 @@ export function useLightboxMediaDetails({
setTagsExpanded, setTagsExpanded,
taggingQueued, taggingQueued,
setTaggingQueued, setTaggingQueued,
}; }
} }
@@ -1,23 +1,23 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from 'react'
import { ImageRecord } from "../../store"; import { ImageRecord } from '../../store'
import { ViewTransform } from "./types"; import { ViewTransform } from './types'
import { zoomViewAt } from "./viewTransform"; import { zoomViewAt } from './viewTransform'
interface UseLightboxNavigationParams { interface UseLightboxNavigationParams {
selectedImage: ImageRecord | null; selectedImage: ImageRecord | null
images: ImageRecord[]; images: ImageRecord[]
currentIndex: number; currentIndex: number
slideshowActive: boolean; slideshowActive: boolean
regionSelectMode: boolean; regionSelectMode: boolean
closeImage: () => void; closeImage: () => void
exitRegionMode: () => void; exitRegionMode: () => void
exitSlideshow: () => void; exitSlideshow: () => void
goSlideshow: (direction: -1 | 1) => void; goSlideshow: (direction: -1 | 1) => void
showSlideshowControls: () => void; showSlideshowControls: () => void
setSlideshowPaused: React.Dispatch<React.SetStateAction<boolean>>; setSlideshowPaused: React.Dispatch<React.SetStateAction<boolean>>
openImage: (image: ImageRecord) => void; openImage: (image: ImageRecord) => void
setView: React.Dispatch<React.SetStateAction<ViewTransform>>; setView: React.Dispatch<React.SetStateAction<ViewTransform>>
clampPan: (view: ViewTransform) => ViewTransform; clampPan: (view: ViewTransform) => ViewTransform
} }
export function useLightboxNavigation({ export function useLightboxNavigation({
@@ -37,58 +37,58 @@ export function useLightboxNavigation({
clampPan, clampPan,
}: UseLightboxNavigationParams) { }: UseLightboxNavigationParams) {
const goPrev = useCallback(() => { const goPrev = useCallback(() => {
if (currentIndex > 0) openImage(images[currentIndex - 1]); if (currentIndex > 0) openImage(images[currentIndex - 1])
}, [currentIndex, images, openImage]); }, [currentIndex, images, openImage])
const goNext = useCallback(() => { const goNext = useCallback(() => {
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]); if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1])
}, [currentIndex, images, openImage]); }, [currentIndex, images, openImage])
useEffect(() => { useEffect(() => {
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
if (!selectedImage) return; if (!selectedImage) return
if (slideshowActive) { if (slideshowActive) {
if (event.key === "Escape") { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault()
exitSlideshow(); exitSlideshow()
return; return
} }
if (event.key === " ") { if (event.key === ' ') {
event.preventDefault(); event.preventDefault()
setSlideshowPaused((paused) => !paused); setSlideshowPaused((paused) => !paused)
showSlideshowControls(); showSlideshowControls()
return; return
} }
if (event.key === "ArrowLeft" && !event.shiftKey) { if (event.key === 'ArrowLeft' && !event.shiftKey) {
event.preventDefault(); event.preventDefault()
goSlideshow(-1); goSlideshow(-1)
return; return
} }
if (event.key === "ArrowRight" && !event.shiftKey) { if (event.key === 'ArrowRight' && !event.shiftKey) {
event.preventDefault(); event.preventDefault()
goSlideshow(1); goSlideshow(1)
return; return
} }
return; return
} }
if (event.key === "Escape") { if (event.key === 'Escape') {
if (regionSelectMode) { if (regionSelectMode) {
exitRegionMode(); exitRegionMode()
} else { } else {
closeImage(); closeImage()
} }
} }
if (regionSelectMode) return; if (regionSelectMode) return
if (event.key === "ArrowLeft" && !event.shiftKey) goPrev(); if (event.key === 'ArrowLeft' && !event.shiftKey) goPrev()
if (event.key === "ArrowRight" && !event.shiftKey) goNext(); if (event.key === 'ArrowRight' && !event.shiftKey) goNext()
if (event.key === "+" || event.key === "=") if (event.key === '+' || event.key === '=')
setView((view) => clampPan(zoomViewAt(view, Math.min(3, view.zoom + 0.25), 0, 0))); setView((view) => clampPan(zoomViewAt(view, Math.min(3, view.zoom + 0.25), 0, 0)))
if (event.key === "-") if (event.key === '-')
setView((view) => clampPan(zoomViewAt(view, Math.max(0.75, view.zoom - 0.25), 0, 0))); setView((view) => clampPan(zoomViewAt(view, Math.max(0.75, view.zoom - 0.25), 0, 0)))
}; }
window.addEventListener("keydown", handler); window.addEventListener('keydown', handler)
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener('keydown', handler)
}, [ }, [
clampPan, clampPan,
closeImage, closeImage,
@@ -103,7 +103,7 @@ export function useLightboxNavigation({
setView, setView,
showSlideshowControls, showSlideshowControls,
slideshowActive, slideshowActive,
]); ])
return { goPrev, goNext }; return { goPrev, goNext }
} }
+110 -87
View File
@@ -1,26 +1,34 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react"; import {
import { ImageRecord } from "../../store"; useCallback,
import { DragRect, ViewTransform } from "./types"; useEffect,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import { ImageRecord } from '../../store'
import { DragRect, ViewTransform } from './types'
import { import {
clampPanToViewport, clampPanToViewport,
MIN_SELECTION_FRACTION, MIN_SELECTION_FRACTION,
normaliseRect, normaliseRect,
rectToNormalisedCrop, rectToNormalisedCrop,
zoomViewAt, zoomViewAt,
} from "./viewTransform"; } from './viewTransform'
interface UseRegionSelectionParams { interface UseRegionSelectionParams {
selectedImage: ImageRecord | null; selectedImage: ImageRecord | null
slideshowActive: boolean; slideshowActive: boolean
imageViewportRef: RefObject<HTMLDivElement | null>; imageViewportRef: RefObject<HTMLDivElement | null>
imgRef: RefObject<HTMLImageElement | null>; imgRef: RefObject<HTMLImageElement | null>
view: ViewTransform; view: ViewTransform
setView: Dispatch<SetStateAction<ViewTransform>>; setView: Dispatch<SetStateAction<ViewTransform>>
findSimilarByRegion: ( findSimilarByRegion: (
imageId: number, imageId: number,
crop: { x: number; y: number; w: number; h: number }, crop: { x: number; y: number; w: number; h: number },
sourceFolderId: number | null, sourceFolderId: number | null
) => Promise<void>; ) => Promise<void>
} }
export function useRegionSelection({ export function useRegionSelection({
@@ -32,126 +40,141 @@ export function useRegionSelection({
setView, setView,
findSimilarByRegion, findSimilarByRegion,
}: UseRegionSelectionParams) { }: UseRegionSelectionParams) {
const [regionSelectMode, setRegionSelectMode] = useState(false); const [regionSelectMode, setRegionSelectMode] = useState(false)
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false)
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false)
const [dragRect, setDragRect] = useState<DragRect | null>(null); const [dragRect, setDragRect] = useState<DragRect | null>(null)
const [regionSearching, setRegionSearching] = useState(false); const [regionSearching, setRegionSearching] = useState(false)
const lastPanPointRef = useRef({ x: 0, y: 0 }); const lastPanPointRef = useRef({ x: 0, y: 0 })
const clampPan = useCallback( const clampPan = useCallback(
(nextView: ViewTransform): ViewTransform => (nextView: ViewTransform): ViewTransform =>
clampPanToViewport(nextView, imgRef.current, imageViewportRef.current), clampPanToViewport(nextView, imgRef.current, imageViewportRef.current),
[imageViewportRef, imgRef], [imageViewportRef, imgRef]
); )
const exitRegionMode = useCallback(() => { const exitRegionMode = useCallback(() => {
setRegionSelectMode(false); setRegionSelectMode(false)
setIsDragging(false); setIsDragging(false)
setDragRect(null); setDragRect(null)
}, []); }, [])
useEffect(() => { useEffect(() => {
const viewport = imageViewportRef.current; const viewport = imageViewportRef.current
if (!viewport || !selectedImage || selectedImage.media_kind !== "image" || slideshowActive) return; if (!viewport || !selectedImage || selectedImage.media_kind !== 'image' || slideshowActive)
return
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
if (regionSelectMode) return; if (regionSelectMode) return
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return
event.preventDefault(); event.preventDefault()
const bounds = viewport.getBoundingClientRect(); const bounds = viewport.getBoundingClientRect()
const anchorX = event.clientX - (bounds.left + bounds.width / 2); const anchorX = event.clientX - (bounds.left + bounds.width / 2)
const anchorY = event.clientY - (bounds.top + bounds.height / 2); const anchorY = event.clientY - (bounds.top + bounds.height / 2)
setView((currentView) => { setView((currentView) => {
const delta = event.deltaY < 0 ? 0.15 : -0.15; const delta = event.deltaY < 0 ? 0.15 : -0.15
const next = Math.min(4, Math.max(0.5, currentView.zoom + delta)); const next = Math.min(4, Math.max(0.5, currentView.zoom + delta))
return clampPan(zoomViewAt(currentView, next, anchorX, anchorY)); return clampPan(zoomViewAt(currentView, next, anchorX, anchorY))
}); })
}; }
viewport.addEventListener("wheel", handleWheel, { passive: false }); viewport.addEventListener('wheel', handleWheel, { passive: false })
return () => viewport.removeEventListener("wheel", handleWheel); return () => viewport.removeEventListener('wheel', handleWheel)
}, [clampPan, imageViewportRef, regionSelectMode, selectedImage, setView, slideshowActive]); }, [clampPan, imageViewportRef, regionSelectMode, selectedImage, setView, slideshowActive])
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
if (!regionSelectMode) { if (!regionSelectMode) {
if (view.zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") { if (view.zoom > 1 && event.button === 0 && selectedImage?.media_kind === 'image') {
event.preventDefault(); event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId)
lastPanPointRef.current = { x: event.clientX, y: event.clientY }; lastPanPointRef.current = { x: event.clientX, y: event.clientY }
setIsPanning(true); setIsPanning(true)
} }
return; return
} }
event.preventDefault(); event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId)
setIsDragging(true); setIsDragging(true)
setDragRect({ setDragRect({
startX: event.clientX, startX: event.clientX,
startY: event.clientY, startY: event.clientY,
endX: event.clientX, endX: event.clientX,
endY: event.clientY, endY: event.clientY,
}); })
}, },
[regionSelectMode, selectedImage?.media_kind, view.zoom], [regionSelectMode, selectedImage?.media_kind, view.zoom]
); )
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) { if (isPanning) {
const dx = event.clientX - lastPanPointRef.current.x; const dx = event.clientX - lastPanPointRef.current.x
const dy = event.clientY - lastPanPointRef.current.y; const dy = event.clientY - lastPanPointRef.current.y
lastPanPointRef.current = { x: event.clientX, y: event.clientY }; lastPanPointRef.current = { x: event.clientX, y: event.clientY }
setView((currentView) => clampPan({ ...currentView, panX: currentView.panX + dx, panY: currentView.panY + dy })); setView((currentView) =>
return; clampPan({ ...currentView, panX: currentView.panX + dx, panY: currentView.panY + dy })
)
return
} }
if (!isDragging) return; if (!isDragging) return
setDragRect((prev) => setDragRect((prev) => (prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null))
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
);
}, },
[clampPan, isDragging, isPanning, setView], [clampPan, isDragging, isPanning, setView]
); )
const handlePointerUp = useCallback( const handlePointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) { if (isPanning) {
event.currentTarget.releasePointerCapture(event.pointerId); event.currentTarget.releasePointerCapture(event.pointerId)
setIsPanning(false); setIsPanning(false)
return; return
} }
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) { if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
setIsDragging(false); setIsDragging(false)
return; return
} }
event.currentTarget.releasePointerCapture(event.pointerId); event.currentTarget.releasePointerCapture(event.pointerId)
const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY }; const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY }
const crop = rectToNormalisedCrop(finalRect, imgRef.current); const crop = rectToNormalisedCrop(finalRect, imgRef.current)
setIsDragging(false); setIsDragging(false)
setDragRect(null); setDragRect(null)
const containerBounds = imageViewportRef.current?.getBoundingClientRect(); const containerBounds = imageViewportRef.current?.getBoundingClientRect()
const containerSize = containerBounds const containerSize = containerBounds
? Math.min(containerBounds.width, containerBounds.height) ? Math.min(containerBounds.width, containerBounds.height)
: 500; : 500
const selW = Math.abs(finalRect.endX - finalRect.startX); const selW = Math.abs(finalRect.endX - finalRect.startX)
const selH = Math.abs(finalRect.endY - finalRect.startY); const selH = Math.abs(finalRect.endY - finalRect.startY)
if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) { if (
exitRegionMode(); !crop ||
return; selW < containerSize * MIN_SELECTION_FRACTION ||
selH < containerSize * MIN_SELECTION_FRACTION
) {
exitRegionMode()
return
} }
exitRegionMode(); exitRegionMode()
setRegionSearching(true); setRegionSearching(true)
void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id) void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id).finally(() =>
.finally(() => setRegionSearching(false)); setRegionSearching(false)
)
}, },
[dragRect, exitRegionMode, findSimilarByRegion, imageViewportRef, imgRef, isDragging, isPanning, selectedImage], [
); dragRect,
exitRegionMode,
findSimilarByRegion,
imageViewportRef,
imgRef,
isDragging,
isPanning,
selectedImage,
]
)
return { return {
regionSelectMode, regionSelectMode,
@@ -165,5 +188,5 @@ export function useRegionSelection({
handlePointerDown, handlePointerDown,
handlePointerMove, handlePointerMove,
handlePointerUp, handlePointerUp,
}; }
} }
+153 -134
View File
@@ -1,37 +1,46 @@
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react"; import {
import type { Transition } from "framer-motion"; useCallback,
import { ImageRecord, SlideshowOrder, SlideshowTransition } from "../../store"; useEffect,
import { IDENTITY_VIEW } from "./viewTransform"; useMemo,
import { ViewTransform } from "./types"; useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import type { Transition } from 'framer-motion'
import { ImageRecord, SlideshowOrder, SlideshowTransition } from '../../store'
import { IDENTITY_VIEW } from './viewTransform'
import { ViewTransform } from './types'
const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]; const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]
const SLIDESHOW_CONTROLS_IDLE_MS = 5000; const SLIDESHOW_CONTROLS_IDLE_MS = 5000
const SLIDESHOW_LOAD_MORE_THRESHOLD = 3; const SLIDESHOW_LOAD_MORE_THRESHOLD = 3
export interface SlideshowMotion { export interface SlideshowMotion {
imageInitial: { opacity: number; filter: string }; imageInitial: { opacity: number; filter: string }
imageAnimate: { opacity: number; filter: string }; imageAnimate: { opacity: number; filter: string }
imageExit: { opacity: number; filter: string }; imageExit: { opacity: number; filter: string }
imageTransition: Transition; imageTransition: Transition
contentInitial: { scale: number; x?: number; y?: number }; contentInitial: { scale: number; x?: number; y?: number }
contentAnimate: { scale: number; x?: number; y?: number }; contentAnimate: { scale: number; x?: number; y?: number }
contentTransition: Transition; contentTransition: Transition
} }
interface UseSlideshowParams { interface UseSlideshowParams {
rootRef: RefObject<HTMLDivElement | null>; rootRef: RefObject<HTMLDivElement | null>
selectedImage: ImageRecord | null; selectedImage: ImageRecord | null
images: ImageRecord[]; images: ImageRecord[]
currentIndex: number; currentIndex: number
loadedCount: number; loadedCount: number
totalImages: number; totalImages: number
intervalSeconds: number; intervalSeconds: number
order: SlideshowOrder; order: SlideshowOrder
transition: SlideshowTransition; transition: SlideshowTransition
openImage: (image: ImageRecord) => void; openImage: (image: ImageRecord) => void
loadMoreImages: () => Promise<void>; loadMoreImages: () => Promise<void>
exitRegionMode: () => void; exitRegionMode: () => void
setView: Dispatch<SetStateAction<ViewTransform>>; setView: Dispatch<SetStateAction<ViewTransform>>
} }
export function useSlideshow({ export function useSlideshow({
@@ -49,190 +58,200 @@ export function useSlideshow({
exitRegionMode, exitRegionMode,
setView, setView,
}: UseSlideshowParams) { }: UseSlideshowParams) {
const [active, setActive] = useState(false); const [active, setActive] = useState(false)
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false)
const [controlsVisible, setControlsVisible] = useState(true); const [controlsVisible, setControlsVisible] = useState(true)
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false)
const [motionStep, setMotionStep] = useState(0); const [motionStep, setMotionStep] = useState(0)
const controlsIdleTimeoutRef = useRef<number | null>(null); const controlsIdleTimeoutRef = useRef<number | null>(null)
const pointerRef = useRef<{ x: number; y: number } | null>(null); const pointerRef = useRef<{ x: number; y: number } | null>(null)
const randomSeenRef = useRef<Set<number>>(new Set()); const randomSeenRef = useRef<Set<number>>(new Set())
const slideshowImages = useMemo( const slideshowImages = useMemo(
() => images.filter((image) => image.media_kind === "image"), () => images.filter((image) => image.media_kind === 'image'),
[images], [images]
); )
const slideshowIndex = selectedImage const slideshowIndex = selectedImage
? slideshowImages.findIndex((image) => image.id === selectedImage.id) ? slideshowImages.findIndex((image) => image.id === selectedImage.id)
: -1; : -1
const position = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1); const position = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1)
const canStart = slideshowImages.length > 0; const canStart = slideshowImages.length > 0
const directions = [ const directions = [
{ x: -1, y: 0.45 }, { x: -1, y: 0.45 },
{ x: 1, y: -0.45 }, { x: 1, y: -0.45 },
{ x: -0.7, y: -0.55 }, { x: -0.7, y: -0.55 },
{ x: 0.7, y: 0.55 }, { x: 0.7, y: 0.55 },
]; ]
const direction = directions[motionStep % directions.length]; const direction = directions[motionStep % directions.length]
const motionConfig: SlideshowMotion = { const motionConfig: SlideshowMotion = {
imageInitial: { opacity: 0, filter: "blur(8px)" }, imageInitial: { opacity: 0, filter: 'blur(8px)' },
imageAnimate: { opacity: 1, filter: "blur(0px)" }, imageAnimate: { opacity: 1, filter: 'blur(0px)' },
imageExit: { opacity: 0, filter: "blur(4px)" }, imageExit: { opacity: 0, filter: 'blur(4px)' },
imageTransition: { duration: 0.72, ease: SLIDESHOW_EASE }, imageTransition: { duration: 0.72, ease: SLIDESHOW_EASE },
contentInitial: contentInitial:
transition === "gentle-motion" transition === 'gentle-motion'
? { scale: 1.012, x: -8 * direction.x, y: 8 * direction.y } ? { scale: 1.012, x: -8 * direction.x, y: 8 * direction.y }
: { scale: 1.012 }, : { scale: 1.012 },
contentAnimate: contentAnimate:
transition === "gentle-motion" transition === 'gentle-motion'
? { scale: 1.026, x: 8 * direction.x, y: -8 * direction.y } ? { scale: 1.026, x: 8 * direction.x, y: -8 * direction.y }
: { scale: 1 }, : { scale: 1 },
contentTransition: contentTransition:
transition === "gentle-motion" transition === 'gentle-motion'
? { duration: intervalSeconds + 0.75, ease: "linear" } ? { duration: intervalSeconds + 0.75, ease: 'linear' }
: { duration: 0.72, ease: SLIDESHOW_EASE }, : { duration: 0.72, ease: SLIDESHOW_EASE },
}; }
const showControls = useCallback(() => { const showControls = useCallback(() => {
if (controlsIdleTimeoutRef.current !== null) { if (controlsIdleTimeoutRef.current !== null) {
window.clearTimeout(controlsIdleTimeoutRef.current); window.clearTimeout(controlsIdleTimeoutRef.current)
controlsIdleTimeoutRef.current = null; controlsIdleTimeoutRef.current = null
} }
setControlsVisible(true); setControlsVisible(true)
if (active) { if (active) {
controlsIdleTimeoutRef.current = window.setTimeout(() => { controlsIdleTimeoutRef.current = window.setTimeout(() => {
setControlsVisible(false); setControlsVisible(false)
controlsIdleTimeoutRef.current = null; controlsIdleTimeoutRef.current = null
}, SLIDESHOW_CONTROLS_IDLE_MS); }, SLIDESHOW_CONTROLS_IDLE_MS)
} }
}, [active]); }, [active])
const exit = useCallback(() => { const exit = useCallback(() => {
setActive(false); setActive(false)
setPaused(false); setPaused(false)
setControlsVisible(true); setControlsVisible(true)
if (document.fullscreenElement === rootRef.current) { if (document.fullscreenElement === rootRef.current) {
void document.exitFullscreen().catch(() => undefined); void document.exitFullscreen().catch(() => undefined)
} }
}, [rootRef]); }, [rootRef])
const go = useCallback( const go = useCallback(
(direction: -1 | 1, revealControls = true) => { (direction: -1 | 1, revealControls = true) => {
if (slideshowImages.length === 0) return; if (slideshowImages.length === 0) return
if (slideshowImages.length === 1) { if (slideshowImages.length === 1) {
openImage(slideshowImages[0]); openImage(slideshowImages[0])
return; return
} }
const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0; const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0
let nextIndex = let nextIndex =
(currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length; (currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length
if (order === "random") { if (order === 'random') {
const currentId = slideshowImages[currentSlideshowIndex]?.id; const currentId = slideshowImages[currentSlideshowIndex]?.id
let candidates = slideshowImages.filter( let candidates = slideshowImages.filter(
(image) => image.id !== currentId && !randomSeenRef.current.has(image.id), (image) => image.id !== currentId && !randomSeenRef.current.has(image.id)
); )
if (candidates.length === 0) { if (candidates.length === 0) {
randomSeenRef.current = new Set(currentId == null ? [] : [currentId]); randomSeenRef.current = new Set(currentId == null ? [] : [currentId])
candidates = slideshowImages.filter((image) => image.id !== currentId); candidates = slideshowImages.filter((image) => image.id !== currentId)
} }
const nextImage = candidates[Math.floor(Math.random() * candidates.length)]; const nextImage = candidates[Math.floor(Math.random() * candidates.length)]
nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id); nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id)
randomSeenRef.current.add(nextImage.id); randomSeenRef.current.add(nextImage.id)
} }
setMotionStep((step) => step + 1); setMotionStep((step) => step + 1)
openImage(slideshowImages[nextIndex]); openImage(slideshowImages[nextIndex])
if (revealControls) { if (revealControls) {
showControls(); showControls()
} }
}, },
[openImage, order, showControls, slideshowImages, slideshowIndex], [openImage, order, showControls, slideshowImages, slideshowIndex]
); )
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
const previous = pointerRef.current; const previous = pointerRef.current
const next = { x: event.clientX, y: event.clientY }; const next = { x: event.clientX, y: event.clientY }
pointerRef.current = next; pointerRef.current = next
if (!previous) { if (!previous) {
if (!controlsVisible) { if (!controlsVisible) {
showControls(); showControls()
} }
return; return
} }
if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) { if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) {
showControls(); showControls()
} }
}, },
[controlsVisible, showControls], [controlsVisible, showControls]
); )
const start = useCallback(() => { const start = useCallback(() => {
if (!selectedImage || slideshowImages.length === 0) return; if (!selectedImage || slideshowImages.length === 0) return
const nextImage = const nextImage =
selectedImage.media_kind === "image" selectedImage.media_kind === 'image'
? selectedImage ? selectedImage
: images.slice(Math.max(0, currentIndex + 1)).find((image) => image.media_kind === "image") ?? : (images
slideshowImages[0]; .slice(Math.max(0, currentIndex + 1))
.find((image) => image.media_kind === 'image') ?? slideshowImages[0])
if (nextImage.id !== selectedImage.id) { if (nextImage.id !== selectedImage.id) {
openImage(nextImage); openImage(nextImage)
} }
randomSeenRef.current = new Set([nextImage.id]); randomSeenRef.current = new Set([nextImage.id])
setMotionStep(0); setMotionStep(0)
exitRegionMode(); exitRegionMode()
setView(IDENTITY_VIEW); setView(IDENTITY_VIEW)
setPaused(false); setPaused(false)
setControlsVisible(true); setControlsVisible(true)
pointerRef.current = null; pointerRef.current = null
setActive(true); setActive(true)
void rootRef.current?.requestFullscreen().catch(() => undefined); void rootRef.current?.requestFullscreen().catch(() => undefined)
}, [currentIndex, exitRegionMode, images, openImage, rootRef, selectedImage, setView, slideshowImages]); }, [
currentIndex,
exitRegionMode,
images,
openImage,
rootRef,
selectedImage,
setView,
slideshowImages,
])
useEffect(() => { useEffect(() => {
if (selectedImage) return; if (selectedImage) return
setActive(false); setActive(false)
setPaused(false); setPaused(false)
setControlsVisible(true); setControlsVisible(true)
}, [selectedImage]); }, [selectedImage])
useEffect(() => { useEffect(() => {
const handleFullscreenChange = () => { const handleFullscreenChange = () => {
if (!active) return; if (!active) return
if (document.fullscreenElement !== rootRef.current) { if (document.fullscreenElement !== rootRef.current) {
setActive(false); setActive(false)
setPaused(false); setPaused(false)
setControlsVisible(true); setControlsVisible(true)
} }
}; }
document.addEventListener("fullscreenchange", handleFullscreenChange); document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [active, rootRef]); }, [active, rootRef])
useEffect(() => { useEffect(() => {
showControls(); showControls()
return () => { return () => {
if (controlsIdleTimeoutRef.current !== null) { if (controlsIdleTimeoutRef.current !== null) {
window.clearTimeout(controlsIdleTimeoutRef.current); window.clearTimeout(controlsIdleTimeoutRef.current)
controlsIdleTimeoutRef.current = null; controlsIdleTimeoutRef.current = null
} }
}; }
}, [active, paused, showControls]); }, [active, paused, showControls])
useEffect(() => { useEffect(() => {
if (!active || paused || slideshowImages.length <= 1) return; if (!active || paused || slideshowImages.length <= 1) return
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
go(1, false); go(1, false)
}, intervalSeconds * 1000); }, intervalSeconds * 1000)
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout)
}, [active, go, intervalSeconds, paused, selectedImage?.id, slideshowImages.length]); }, [active, go, intervalSeconds, paused, selectedImage?.id, slideshowImages.length])
useEffect(() => { useEffect(() => {
if ( if (
@@ -242,11 +261,11 @@ export function useSlideshow({
slideshowImages.length === 0 || slideshowImages.length === 0 ||
slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD
) { ) {
return; return
} }
setLoadingMore(true); setLoadingMore(true)
void loadMoreImages().finally(() => setLoadingMore(false)); void loadMoreImages().finally(() => setLoadingMore(false))
}, [ }, [
active, active,
loadedCount, loadedCount,
@@ -255,7 +274,7 @@ export function useSlideshow({
slideshowImages.length, slideshowImages.length,
slideshowIndex, slideshowIndex,
totalImages, totalImages,
]); ])
return { return {
active, active,
@@ -272,5 +291,5 @@ export function useSlideshow({
go, go,
showControls, showControls,
handlePointerMove, handlePointerMove,
}; }
} }
+38 -30
View File
@@ -1,9 +1,9 @@
import { DragRect, NormalizedCrop, SelectionOverlay, ViewTransform } from "./types"; import { DragRect, NormalizedCrop, SelectionOverlay, ViewTransform } from './types'
export const MIN_SELECTION_FRACTION = 0.02; export const MIN_SELECTION_FRACTION = 0.02
export const MIN_ZOOM = 0.5; export const MIN_ZOOM = 0.5
export const MAX_ZOOM = 4; export const MAX_ZOOM = 4
export const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }; export const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }
export function normaliseRect(r: DragRect): SelectionOverlay { export function normaliseRect(r: DragRect): SelectionOverlay {
return { return {
@@ -11,57 +11,65 @@ export function normaliseRect(r: DragRect): SelectionOverlay {
top: Math.min(r.startY, r.endY), top: Math.min(r.startY, r.endY),
width: Math.abs(r.endX - r.startX), width: Math.abs(r.endX - r.startX),
height: Math.abs(r.endY - r.startY), height: Math.abs(r.endY - r.startY),
}; }
} }
export function rectToNormalisedCrop(rect: DragRect, imgEl: HTMLImageElement): NormalizedCrop | null { export function rectToNormalisedCrop(
const imgBounds = imgEl.getBoundingClientRect(); rect: DragRect,
if (imgBounds.width === 0 || imgBounds.height === 0) return null; imgEl: HTMLImageElement
): NormalizedCrop | null {
const imgBounds = imgEl.getBoundingClientRect()
if (imgBounds.width === 0 || imgBounds.height === 0) return null
const rawX = Math.min(rect.startX, rect.endX); const rawX = Math.min(rect.startX, rect.endX)
const rawY = Math.min(rect.startY, rect.endY); const rawY = Math.min(rect.startY, rect.endY)
const rawW = Math.abs(rect.endX - rect.startX); const rawW = Math.abs(rect.endX - rect.startX)
const rawH = Math.abs(rect.endY - rect.startY); const rawH = Math.abs(rect.endY - rect.startY)
const clampedX = Math.max(rawX, imgBounds.left); const clampedX = Math.max(rawX, imgBounds.left)
const clampedY = Math.max(rawY, imgBounds.top); const clampedY = Math.max(rawY, imgBounds.top)
const clampedRight = Math.min(rawX + rawW, imgBounds.right); const clampedRight = Math.min(rawX + rawW, imgBounds.right)
const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom); const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom)
const croppedW = clampedRight - clampedX; const croppedW = clampedRight - clampedX
const croppedH = clampedBottom - clampedY; const croppedH = clampedBottom - clampedY
if (croppedW <= 0 || croppedH <= 0) return null; if (croppedW <= 0 || croppedH <= 0) return null
return { return {
x: (clampedX - imgBounds.left) / imgBounds.width, x: (clampedX - imgBounds.left) / imgBounds.width,
y: (clampedY - imgBounds.top) / imgBounds.height, y: (clampedY - imgBounds.top) / imgBounds.height,
w: croppedW / imgBounds.width, w: croppedW / imgBounds.width,
h: croppedH / imgBounds.height, h: croppedH / imgBounds.height,
}; }
} }
export function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform { export function zoomViewAt(
if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom }; view: ViewTransform,
const ratio = newZoom / view.zoom; newZoom: number,
anchorX: number,
anchorY: number
): ViewTransform {
if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom }
const ratio = newZoom / view.zoom
return { return {
zoom: newZoom, zoom: newZoom,
panX: anchorX - (anchorX - view.panX) * ratio, panX: anchorX - (anchorX - view.panX) * ratio,
panY: anchorY - (anchorY - view.panY) * ratio, panY: anchorY - (anchorY - view.panY) * ratio,
}; }
} }
export function clampPanToViewport( export function clampPanToViewport(
view: ViewTransform, view: ViewTransform,
img: HTMLImageElement | null, img: HTMLImageElement | null,
viewport: HTMLDivElement | null, viewport: HTMLDivElement | null
): ViewTransform { ): ViewTransform {
if (!img || !viewport) return view; if (!img || !viewport) return view
const maxX = Math.max(0, (img.offsetWidth * view.zoom - viewport.clientWidth) / 2); const maxX = Math.max(0, (img.offsetWidth * view.zoom - viewport.clientWidth) / 2)
const maxY = Math.max(0, (img.offsetHeight * view.zoom - viewport.clientHeight) / 2); const maxY = Math.max(0, (img.offsetHeight * view.zoom - viewport.clientHeight) / 2)
return { return {
...view, ...view,
panX: Math.min(maxX, Math.max(-maxX, view.panX)), panX: Math.min(maxX, Math.max(-maxX, view.panX)),
panY: Math.min(maxY, Math.max(-maxY, view.panY)), panY: Math.min(maxY, Math.max(-maxY, view.panY)),
}; }
} }
+26 -26
View File
@@ -1,7 +1,7 @@
import { ReactNode, useLayoutEffect, useRef, useState } from "react"; import { ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from "react-dom"; import { createPortal } from 'react-dom'
import { MenuCloseContext, MenuPanel, MenuSize } from "./Menu"; import { MenuCloseContext, MenuPanel, MenuSize } from './Menu'
import { useDismissable } from "./useDismissable"; import { useDismissable } from './useDismissable'
/** /**
* Positioned floating menu. Renders through a portal so it is never caught * Positioned floating menu. Renders through a portal so it is never caught
@@ -16,35 +16,35 @@ export function ContextMenu({
x, x,
y, y,
onClose, onClose,
size = "md", size = 'md',
align = "start", align = 'start',
className, className,
children, children,
}: { }: {
x: number; x: number
y: number; y: number
onClose: () => void; onClose: () => void
size?: MenuSize; size?: MenuSize
align?: "start" | "end"; align?: 'start' | 'end'
className?: string; className?: string
children: ReactNode; children: ReactNode
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null); const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
useDismissable(ref, onClose); useDismissable(ref, onClose)
useLayoutEffect(() => { useLayoutEffect(() => {
const el = ref.current; const el = ref.current
if (!el) return; if (!el) return
const margin = 8; const margin = 8
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect()
const desiredLeft = align === "end" ? x - rect.width : x; const desiredLeft = align === 'end' ? x - rect.width : x
setPos({ setPos({
left: Math.max(margin, Math.min(desiredLeft, window.innerWidth - rect.width - margin)), left: Math.max(margin, Math.min(desiredLeft, window.innerWidth - rect.width - margin)),
top: Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin)), top: Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin)),
}); })
}, [x, y, align]); }, [x, y, align])
return createPortal( return createPortal(
<div <div
@@ -52,7 +52,7 @@ export function ContextMenu({
className="fixed z-50" className="fixed z-50"
// Render hidden at the raw coordinates for the measuring pass; the // Render hidden at the raw coordinates for the measuring pass; the
// layout effect swaps in the clamped position before paint. // layout effect swaps in the clamped position before paint.
style={pos ?? { left: x, top: y, visibility: "hidden" }} style={pos ?? { left: x, top: y, visibility: 'hidden' }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
> >
@@ -62,6 +62,6 @@ export function ContextMenu({
</MenuPanel> </MenuPanel>
</MenuCloseContext.Provider> </MenuCloseContext.Provider>
</div>, </div>,
document.body, document.body
); )
} }
+47 -45
View File
@@ -1,39 +1,39 @@
import { ReactNode, useRef, useState } from "react"; import { ReactNode, useRef, useState } from 'react'
import { MenuCloseContext, MenuItem, MenuPanel, MenuSize } from "./Menu"; import { MenuCloseContext, MenuItem, MenuPanel, MenuSize } from './Menu'
import { useDismissable } from "./useDismissable"; import { useDismissable } from './useDismissable'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { ChevronDownIcon } from "../icons"; import { ChevronDownIcon } from '../icons'
export interface DropdownOption<T> { export interface DropdownOption<T> {
value: T; value: T
label: string; label: string
/** Trailing detail on the option row (e.g. an item count). */ /** Trailing detail on the option row (e.g. an item count). */
hint?: ReactNode; hint?: ReactNode
} }
const TRIGGER_STYLES = { const TRIGGER_STYLES = {
// Filled, bordered select — settings forms and panel headers. // Filled, bordered select — settings forms and panel headers.
solid: { solid: {
base: "min-w-40 gap-2 rounded-md border px-3 py-1.5 text-xs border-white/10 bg-white/[0.055] light-theme:border-gray-700/50 light-theme:bg-gray-900", base: 'min-w-40 gap-2 rounded-md border px-3 py-1.5 text-xs border-white/10 bg-white/[0.055] light-theme:border-gray-700/50 light-theme:bg-gray-900',
open: "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white", open: 'border-white/20 text-white light-theme:border-gray-700 light-theme:text-white',
closed: closed:
"text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white", 'text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white',
}, },
// Transparent until engaged — toolbar controls (sort, folder scope). // Transparent until engaged — toolbar controls (sort, folder scope).
ghost: { ghost: {
base: "gap-1.5 rounded-lg border px-3 py-1.5 text-xs", base: 'gap-1.5 rounded-lg border px-3 py-1.5 text-xs',
open: "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white", open: 'border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white',
closed: closed:
"border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white", 'border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white',
}, },
// Tiny inline picker — sidebar section headers. // Tiny inline picker — sidebar section headers.
compact: { compact: {
base: "gap-2 rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-white/10 bg-white/[0.04] light-theme:border-gray-700/50 light-theme:bg-gray-900", base: 'gap-2 rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-white/10 bg-white/[0.04] light-theme:border-gray-700/50 light-theme:bg-gray-900',
open: "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white", open: 'border-white/20 text-white light-theme:border-gray-700 light-theme:text-white',
closed: closed:
"text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white", 'text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white',
}, },
} as const; } as const
/** /**
* The app-wide select control: a trigger button plus a MenuPanel of options. * The app-wide select control: a trigger button plus a MenuPanel of options.
@@ -46,32 +46,32 @@ export function Dropdown<T extends string | number | null>({
options, options,
onChange, onChange,
ariaLabel, ariaLabel,
align = "right", align = 'right',
trigger = "solid", trigger = 'solid',
size = "sm", size = 'sm',
triggerIcon, triggerIcon,
triggerTooltip, triggerTooltip,
triggerClassName = "", triggerClassName = '',
panelClassName = "", panelClassName = '',
}: { }: {
value: T; value: T
options: DropdownOption<T>[]; options: DropdownOption<T>[]
onChange: (value: T) => void; onChange: (value: T) => void
ariaLabel: string; ariaLabel: string
align?: "left" | "right"; align?: 'left' | 'right'
trigger?: keyof typeof TRIGGER_STYLES; trigger?: keyof typeof TRIGGER_STYLES
size?: MenuSize; size?: MenuSize
triggerIcon?: ReactNode; triggerIcon?: ReactNode
triggerTooltip?: string; triggerTooltip?: string
triggerClassName?: string; triggerClassName?: string
panelClassName?: string; panelClassName?: string
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null)
const current = options.find((option) => Object.is(option.value, value)) ?? options[0]; const current = options.find((option) => Object.is(option.value, value)) ?? options[0]
const style = TRIGGER_STYLES[trigger]; const style = TRIGGER_STYLES[trigger]
useDismissable(ref, () => setOpen(false), open); useDismissable(ref, () => setOpen(false), open)
const button = ( const button = (
<button <button
@@ -86,9 +86,11 @@ export function Dropdown<T extends string | number | null>({
> >
{triggerIcon} {triggerIcon}
<span className="min-w-0 truncate">{current?.label}</span> <span className="min-w-0 truncate">{current?.label}</span>
<ChevronDownIcon className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`} /> <ChevronDownIcon
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</button> </button>
); )
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="relative">
@@ -103,12 +105,12 @@ export function Dropdown<T extends string | number | null>({
<div <div
role="listbox" role="listbox"
aria-label={ariaLabel} aria-label={ariaLabel}
className={`absolute top-full z-50 mt-1.5 min-w-full ${align === "right" ? "right-0" : "left-0"}`} className={`absolute top-full z-50 mt-1.5 min-w-full ${align === 'right' ? 'right-0' : 'left-0'}`}
> >
<MenuCloseContext.Provider value={() => setOpen(false)}> <MenuCloseContext.Provider value={() => setOpen(false)}>
<MenuPanel size={size} className={panelClassName}> <MenuPanel size={size} className={panelClassName}>
{options.map((option) => { {options.map((option) => {
const selected = Object.is(option.value, value); const selected = Object.is(option.value, value)
return ( return (
<MenuItem <MenuItem
key={String(option.value)} key={String(option.value)}
@@ -119,12 +121,12 @@ export function Dropdown<T extends string | number | null>({
checked={selected} checked={selected}
onSelect={() => onChange(option.value)} onSelect={() => onChange(option.value)}
/> />
); )
})} })}
</MenuPanel> </MenuPanel>
</MenuCloseContext.Provider> </MenuCloseContext.Provider>
</div> </div>
) : null} ) : null}
</div> </div>
); )
} }
+93 -88
View File
@@ -6,34 +6,34 @@ import {
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
} from "react"; } from 'react'
import { CheckIcon, ChevronRightIcon } from "../icons"; import { CheckIcon, ChevronRightIcon } from '../icons'
export type MenuSize = "sm" | "md"; export type MenuSize = 'sm' | 'md'
/** Provided by ContextMenu so any nested MenuItem (submenus included) can close the whole menu. */ /** Provided by ContextMenu so any nested MenuItem (submenus included) can close the whole menu. */
export const MenuCloseContext = createContext<() => void>(() => {}); export const MenuCloseContext = createContext<() => void>(() => {})
const MenuSizeContext = createContext<MenuSize>("md"); const MenuSizeContext = createContext<MenuSize>('md')
function itemClass(size: MenuSize, danger: boolean, disabled: boolean, active = false): string { function itemClass(size: MenuSize, danger: boolean, disabled: boolean, active = false): string {
const base = const base =
size === "sm" size === 'sm'
? "w-full rounded-md px-3 py-1.5 text-[12px]" ? 'w-full rounded-md px-3 py-1.5 text-[12px]'
: "w-full rounded-lg px-3 py-2 text-sm"; : 'w-full rounded-lg px-3 py-2 text-sm'
const tone = disabled const tone = disabled
? "text-gray-600 cursor-not-allowed" ? 'text-gray-600 cursor-not-allowed'
: danger : danger
? "text-red-400 hover:bg-red-500/15 hover:text-red-300" ? 'text-red-400 hover:bg-red-500/15 hover:text-red-300'
: active : active
? "bg-white/[0.08] text-white" ? 'bg-white/[0.08] text-white'
: "text-gray-200 hover:bg-white/[0.06] hover:text-white"; : 'text-gray-200 hover:bg-white/[0.06] hover:text-white'
// menu-item + data attributes are the stable hooks the subtle-light theme // menu-item + data attributes are the stable hooks the subtle-light theme
// targets in index.css — keep them if the utility classes change. // targets in index.css — keep them if the utility classes change.
return `menu-item ${base} ${tone} transition-colors`; return `menu-item ${base} ${tone} transition-colors`
} }
function itemTone(danger: boolean, disabled: boolean): "danger" | "disabled" | "default" { function itemTone(danger: boolean, disabled: boolean): 'danger' | 'disabled' | 'default' {
return danger ? "danger" : disabled ? "disabled" : "default"; return danger ? 'danger' : disabled ? 'disabled' : 'default'
} }
/** /**
@@ -42,32 +42,32 @@ function itemTone(danger: boolean, disabled: boolean): "danger" | "disabled" | "
*/ */
export function MenuPanel({ export function MenuPanel({
size, size,
className = "", className = '',
children, children,
}: { }: {
size?: MenuSize; size?: MenuSize
className?: string; className?: string
children: ReactNode; children: ReactNode
}) { }) {
const inherited = useContext(MenuSizeContext); const inherited = useContext(MenuSizeContext)
const resolved = size ?? inherited; const resolved = size ?? inherited
// Default min-width steps aside when the caller sets its own width class — // Default min-width steps aside when the caller sets its own width class —
// Tailwind resolves competing min-w utilities by stylesheet order, not // Tailwind resolves competing min-w utilities by stylesheet order, not
// className order, so merging both would be unpredictable. // className order, so merging both would be unpredictable.
const widthClass = /(^|\s)(min-w-|w-)/.test(className) const widthClass = /(^|\s)(min-w-|w-)/.test(className)
? "" ? ''
: resolved === "sm" : resolved === 'sm'
? "min-w-40" ? 'min-w-40'
: "min-w-52"; : 'min-w-52'
return ( return (
<MenuSizeContext.Provider value={resolved}> <MenuSizeContext.Provider value={resolved}>
<div <div
className={`menu-panel ${widthClass} rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/40 backdrop-blur light-theme:border-gray-700/50 ${className}`} className={`menu-panel ${widthClass} light-theme:border-gray-700/50 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/40 backdrop-blur ${className}`}
> >
{children} {children}
</div> </div>
</MenuSizeContext.Provider> </MenuSizeContext.Provider>
); )
} }
export function MenuItem({ export function MenuItem({
@@ -81,60 +81,63 @@ export function MenuItem({
keepOpen = false, keepOpen = false,
role, role,
}: { }: {
label: ReactNode; label: ReactNode
onSelect?: () => void; onSelect?: () => void
danger?: boolean; danger?: boolean
disabled?: boolean; disabled?: boolean
/** Highlights the row as the current choice (dropdown selections, active view). */ /** Highlights the row as the current choice (dropdown selections, active view). */
active?: boolean; active?: boolean
/** Renders a trailing check mark; use for exclusive-choice menus (themes, sort orders). */ /** Renders a trailing check mark; use for exclusive-choice menus (themes, sort orders). */
checked?: boolean; checked?: boolean
hint?: ReactNode; hint?: ReactNode
/** Skip the automatic menu close after selecting. */ /** Skip the automatic menu close after selecting. */
keepOpen?: boolean; keepOpen?: boolean
role?: React.AriaRole; role?: React.AriaRole
}) { }) {
const size = useContext(MenuSizeContext); const size = useContext(MenuSizeContext)
const close = useContext(MenuCloseContext); const close = useContext(MenuCloseContext)
return ( return (
<button <button
type="button" type="button"
role={role} role={role}
aria-selected={role === "option" ? checked ?? active : undefined} aria-selected={role === 'option' ? (checked ?? active) : undefined}
disabled={disabled} disabled={disabled}
data-tone={itemTone(danger, disabled)} data-tone={itemTone(danger, disabled)}
data-active={active || undefined} data-active={active || undefined}
className={`${itemClass(size, danger, disabled, active)} flex items-center justify-between gap-3`} className={`${itemClass(size, danger, disabled, active)} flex items-center justify-between gap-3`}
onClick={() => { onClick={() => {
if (disabled) return; if (disabled) return
onSelect?.(); onSelect?.()
if (!keepOpen) close(); if (!keepOpen) close()
}} }}
> >
<span className="min-w-0 flex-1 truncate text-left">{label}</span> <span className="min-w-0 flex-1 truncate text-left">{label}</span>
{hint != null ? ( {hint != null ? (
<span className={`shrink-0 ${size === "sm" ? "text-[10px]" : "text-xs"} text-gray-500`}>{hint}</span> <span className={`shrink-0 ${size === 'sm' ? 'text-[10px]' : 'text-xs'} text-gray-500`}>
) : null} {hint}
{checked ? ( </span>
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-blue-400" />
) : null} ) : null}
{checked ? <CheckIcon className="h-3.5 w-3.5 shrink-0 text-blue-400" /> : null}
</button> </button>
); )
} }
export function MenuSeparator() { export function MenuSeparator() {
return <div className="my-1 h-px bg-white/[0.06]"/>; return <div className="my-1 h-px bg-white/[0.06]" />
} }
export function MenuLabel({ children }: { children: ReactNode }) { export function MenuLabel({ children }: { children: ReactNode }) {
return ( return (
<div className="px-3 py-1 text-[10px] uppercase tracking-[0.18em] text-gray-600" <div
style={{ className="px-3 py-1 text-[10px] tracking-[0.18em] text-gray-600 uppercase"
userSelect: 'none', style={{
WebkitUserSelect: 'none', userSelect: 'none',
}} WebkitUserSelect: 'none',
>{children}</div> }}
); >
{children}
</div>
)
} }
/** /**
@@ -146,55 +149,55 @@ export function SubMenu({
label, label,
disabled = false, disabled = false,
children, children,
panelClassName = "", panelClassName = '',
}: { }: {
label: ReactNode; label: ReactNode
disabled?: boolean; disabled?: boolean
children: ReactNode; children: ReactNode
panelClassName?: string; panelClassName?: string
}) { }) {
const size = useContext(MenuSizeContext); const size = useContext(MenuSizeContext)
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
const [placement, setPlacement] = useState({ flipX: false, shiftY: 0 }); const [placement, setPlacement] = useState({ flipX: false, shiftY: 0 })
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null)
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect( useEffect(
() => () => { () => () => {
if (closeTimer.current) clearTimeout(closeTimer.current); if (closeTimer.current) clearTimeout(closeTimer.current)
}, },
[], []
); )
const openNow = () => { const openNow = () => {
if (disabled) return; if (disabled) return
if (closeTimer.current) { if (closeTimer.current) {
clearTimeout(closeTimer.current); clearTimeout(closeTimer.current)
closeTimer.current = null; closeTimer.current = null
} }
setOpen(true); setOpen(true)
}; }
// Grace delay so the pointer can cross the small gap to the panel. // Grace delay so the pointer can cross the small gap to the panel.
const closeSoon = () => { const closeSoon = () => {
if (closeTimer.current) clearTimeout(closeTimer.current); if (closeTimer.current) clearTimeout(closeTimer.current)
closeTimer.current = setTimeout(() => setOpen(false), 140); closeTimer.current = setTimeout(() => setOpen(false), 140)
}; }
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) { if (!open) {
setPlacement({ flipX: false, shiftY: 0 }); setPlacement({ flipX: false, shiftY: 0 })
return; return
} }
const panel = panelRef.current; const panel = panelRef.current
if (!panel) return; if (!panel) return
const margin = 8; const margin = 8
const rect = panel.getBoundingClientRect(); const rect = panel.getBoundingClientRect()
const overflowY = rect.bottom - (window.innerHeight - margin); const overflowY = rect.bottom - (window.innerHeight - margin)
setPlacement({ setPlacement({
flipX: rect.right > window.innerWidth - margin, flipX: rect.right > window.innerWidth - margin,
shiftY: overflowY > 0 ? -overflowY : 0, shiftY: overflowY > 0 ? -overflowY : 0,
}); })
}, [open]); }, [open])
return ( return (
<div className="relative" onPointerEnter={openNow} onPointerLeave={closeSoon}> <div className="relative" onPointerEnter={openNow} onPointerLeave={closeSoon}>
@@ -215,12 +218,14 @@ export function SubMenu({
{open ? ( {open ? (
<div <div
ref={panelRef} ref={panelRef}
className={`absolute top-0 -mt-1 z-10 ${placement.flipX ? "right-full mr-0.5" : "left-full ml-0.5"}`} className={`absolute top-0 z-10 -mt-1 ${placement.flipX ? 'right-full mr-0.5' : 'left-full ml-0.5'}`}
style={placement.shiftY !== 0 ? { transform: `translateY(${placement.shiftY}px)` } : undefined} style={
placement.shiftY !== 0 ? { transform: `translateY(${placement.shiftY}px)` } : undefined
}
> >
<MenuPanel className={panelClassName}>{children}</MenuPanel> <MenuPanel className={panelClassName}>{children}</MenuPanel>
</div> </div>
) : null} ) : null}
</div> </div>
); )
} }
+6 -6
View File
@@ -1,6 +1,6 @@
export { ContextMenu } from "./ContextMenu"; export { ContextMenu } from './ContextMenu'
export { Dropdown } from "./Dropdown"; export { Dropdown } from './Dropdown'
export type { DropdownOption } from "./Dropdown"; export type { DropdownOption } from './Dropdown'
export { MenuCloseContext, MenuItem, MenuLabel, MenuPanel, MenuSeparator, SubMenu } from "./Menu"; export { MenuCloseContext, MenuItem, MenuLabel, MenuPanel, MenuSeparator, SubMenu } from './Menu'
export type { MenuSize } from "./Menu"; export type { MenuSize } from './Menu'
export { useDismissable } from "./useDismissable"; export { useDismissable } from './useDismissable'
+14 -14
View File
@@ -1,4 +1,4 @@
import { RefObject, useEffect } from "react"; import { RefObject, useEffect } from 'react'
/** /**
* Closes a floating element on pointer-down outside `ref` or on Escape. * Closes a floating element on pointer-down outside `ref` or on Escape.
@@ -7,22 +7,22 @@ import { RefObject, useEffect } from "react";
export function useDismissable<T extends HTMLElement>( export function useDismissable<T extends HTMLElement>(
ref: RefObject<T | null>, ref: RefObject<T | null>,
onClose: () => void, onClose: () => void,
enabled = true, enabled = true
) { ) {
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
const el = ref.current; const el = ref.current
if (el && !el.contains(event.target as Node)) onClose(); if (el && !el.contains(event.target as Node)) onClose()
}; }
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose(); if (event.key === 'Escape') onClose()
}; }
window.addEventListener("pointerdown", handlePointerDown); window.addEventListener('pointerdown', handlePointerDown)
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown)
return () => { return () => {
window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener('pointerdown', handlePointerDown)
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener('keydown', handleKeyDown)
}; }
}, [ref, onClose, enabled]); }, [ref, onClose, enabled])
} }
+49 -47
View File
@@ -1,54 +1,54 @@
import { useEffect } from "react"; import { useEffect } from 'react'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from "../../store"; import { useGalleryStore } from '../../store'
import { StepWelcome } from "./StepWelcome"; import { StepWelcome } from './StepWelcome'
import { StepAddLibrary } from "./StepAddLibrary"; import { StepAddLibrary } from './StepAddLibrary'
import { StepPipeline } from "./StepPipeline"; import { StepPipeline } from './StepPipeline'
import { StepGalleryPreview } from "./StepGalleryPreview"; import { StepGalleryPreview } from './StepGalleryPreview'
import { StepSearchDemo } from "./StepSearchDemo"; import { StepSearchDemo } from './StepSearchDemo'
import { StepViews } from "./StepViews"; import { StepViews } from './StepViews'
import { StepAiFeatures } from "./StepAiFeatures"; import { StepAiFeatures } from './StepAiFeatures'
import { StepUpdates } from "./StepUpdates"; import { StepUpdates } from './StepUpdates'
const STEPS: { id: string; title: string; component: () => React.ReactNode }[] = [ const STEPS: { id: string; title: string; component: () => React.ReactNode }[] = [
{ id: "welcome", title: "Welcome", component: () => <StepWelcome /> }, { id: 'welcome', title: 'Welcome', component: () => <StepWelcome /> },
{ id: "library", title: "Your library", component: () => <StepAddLibrary /> }, { id: 'library', title: 'Your library', component: () => <StepAddLibrary /> },
{ id: "pipeline", title: "Background work", component: () => <StepPipeline /> }, { id: 'pipeline', title: 'Background work', component: () => <StepPipeline /> },
{ id: "gallery", title: "The gallery", component: () => <StepGalleryPreview /> }, { id: 'gallery', title: 'The gallery', component: () => <StepGalleryPreview /> },
{ id: "search", title: "Search", component: () => <StepSearchDemo /> }, { id: 'search', title: 'Search', component: () => <StepSearchDemo /> },
{ id: "views", title: "Views", component: () => <StepViews /> }, { id: 'views', title: 'Views', component: () => <StepViews /> },
{ id: "ai", title: "AI features", component: () => <StepAiFeatures /> }, { id: 'ai', title: 'AI features', component: () => <StepAiFeatures /> },
{ id: "updates", title: "Staying current", component: () => <StepUpdates /> }, { id: 'updates', title: 'Staying current', component: () => <StepUpdates /> },
]; ]
export function OnboardingOverlay() { export function OnboardingOverlay() {
const onboardingOpen = useGalleryStore((s) => s.onboardingOpen); const onboardingOpen = useGalleryStore((s) => s.onboardingOpen)
const onboardingStep = useGalleryStore((s) => s.onboardingStep); const onboardingStep = useGalleryStore((s) => s.onboardingStep)
const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep); const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep)
const completeOnboarding = useGalleryStore((s) => s.completeOnboarding); const completeOnboarding = useGalleryStore((s) => s.completeOnboarding)
useEffect(() => { useEffect(() => {
if (!onboardingOpen) return; if (!onboardingOpen) return
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") completeOnboarding(); if (event.key === 'Escape') completeOnboarding()
}; }
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown)
}, [onboardingOpen, completeOnboarding]); }, [onboardingOpen, completeOnboarding])
if (!onboardingOpen) return null; if (!onboardingOpen) return null
const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)]; const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)]
const isFirst = onboardingStep === 0; const isFirst = onboardingStep === 0
const isLast = onboardingStep >= STEPS.length - 1; const isLast = onboardingStep >= STEPS.length - 1
return ( return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm light-theme:bg-black/40"> <div className="light-theme:bg-black/40 fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm">
<div className="flex max-h-[min(85vh,760px)] w-[min(90vw,720px)] flex-col rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70"> <div className="light-theme:border-gray-300/70 flex max-h-[min(85vh,760px)] w-[min(90vw,720px)] flex-col rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.07] px-7 py-4 light-theme:border-gray-300/70"> <div className="light-theme:border-gray-300/70 flex items-center justify-between border-b border-white/[0.07] px-7 py-4">
<div> <div>
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600"> <p className="text-[11px] tracking-[0.14em] text-gray-600 uppercase">
Step {onboardingStep + 1} of {STEPS.length} Step {onboardingStep + 1} of {STEPS.length}
</p> </p>
<h2 className="mt-0.5 text-base font-semibold text-white">{step.title}</h2> <h2 className="mt-0.5 text-base font-semibold text-white">{step.title}</h2>
@@ -60,10 +60,10 @@ export function OnboardingOverlay() {
aria-label={s.title} aria-label={s.title}
className={`h-1.5 rounded-full transition-all ${ className={`h-1.5 rounded-full transition-all ${
i === onboardingStep i === onboardingStep
? "w-5 bg-white/70 light-theme:bg-gray-700" ? 'light-theme:bg-gray-700 w-5 bg-white/70'
: i < onboardingStep : i < onboardingStep
? "w-1.5 bg-white/35 light-theme:bg-gray-400" ? 'light-theme:bg-gray-400 w-1.5 bg-white/35'
: "w-1.5 bg-white/15 light-theme:bg-gray-300" : 'light-theme:bg-gray-300 w-1.5 bg-white/15'
}`} }`}
onClick={() => setOnboardingStep(i)} onClick={() => setOnboardingStep(i)}
/> />
@@ -87,16 +87,16 @@ export function OnboardingOverlay() {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4 light-theme:border-gray-300/70"> <div className="light-theme:border-gray-300/70 flex items-center justify-between border-t border-white/[0.07] px-7 py-4">
<button <button
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
onClick={completeOnboarding} onClick={completeOnboarding}
> >
Skip tour Skip tour
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <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 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white 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={() => setOnboardingStep(onboardingStep - 1)} onClick={() => setOnboardingStep(onboardingStep - 1)}
disabled={isFirst} disabled={isFirst}
> >
@@ -104,13 +104,15 @@ export function OnboardingOverlay() {
</button> </button>
<button <button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25" className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))} onClick={() =>
isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1)
}
> >
{isLast ? "Finish" : "Next"} {isLast ? 'Finish' : 'Next'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
} }
+16 -13
View File
@@ -1,13 +1,13 @@
import { useGalleryStore } from "../../store"; import { useGalleryStore } from '../../store'
import { FakeTile } from "./fakes"; import { FakeTile } from './fakes'
export function StepAddLibrary() { export function StepAddLibrary() {
const folders = useGalleryStore((s) => s.folders); const folders = useGalleryStore((s) => s.folders)
const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen); const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen)
const handlePick = () => { const handlePick = () => {
setFolderPickerOpen(true); setFolderPickerOpen(true)
}; }
return ( return (
<div> <div>
@@ -16,15 +16,18 @@ export function StepAddLibrary() {
added, renamed, or removed. Nothing is moved or copied. added, renamed, or removed. Nothing is moved or copied.
</p> </p>
<div className="mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4 light-theme:border-gray-300/70"> <div className="light-theme:border-gray-300/70 mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4">
<div className="min-w-0"> <div className="min-w-0">
{folders.length > 0 ? ( {folders.length > 0 ? (
<> <>
<p className="text-sm text-white"> <p className="text-sm text-white">
{folders.length === 1 ? `${folders[0].name}” added` : `${folders.length} folders in your library`} {folders.length === 1
? `${folders[0].name}” added`
: `${folders.length} folders in your library`}
</p> </p>
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
Indexing runs in the background you can add more folders from the sidebar any time. Indexing runs in the background you can add more folders from the sidebar any
time.
</p> </p>
</> </>
) : ( ) : (
@@ -40,13 +43,13 @@ export function StepAddLibrary() {
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45" className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
onClick={handlePick} onClick={handlePick}
> >
{folders.length > 0 ? "Add another folder" : "Choose a folder"} {folders.length > 0 ? 'Add another folder' : 'Choose a folder'}
</button> </button>
</div> </div>
<p className="mt-5 text-xs leading-relaxed text-gray-500"> <p className="mt-5 text-xs leading-relaxed text-gray-500">
As indexing runs, the gallery fills in roughly like this tiles appear immediately and sharpen As indexing runs, the gallery fills in roughly like this tiles appear immediately and
as thumbnails are generated: sharpen as thumbnails are generated:
</p> </p>
<div className="media-dark-surface mt-3 grid grid-cols-6 gap-1.5"> <div className="media-dark-surface mt-3 grid grid-cols-6 gap-1.5">
<FakeTile index={0} /> <FakeTile index={0} />
@@ -57,5 +60,5 @@ export function StepAddLibrary() {
<FakeTile index={5} loaded={false} /> <FakeTile index={5} loaded={false} />
</div> </div>
</div> </div>
); )
} }
+88 -58
View File
@@ -1,62 +1,75 @@
import { useEffect } from "react"; import { useEffect } from 'react'
import { TaggerModel, TaggerModelProgress, useGalleryStore } from "../../store"; import { TaggerModel, TaggerModelProgress, useGalleryStore } from '../../store'
import { TAGGER_MODELS } from "../../taggerModels"; import { TAGGER_MODELS } from '../../taggerModels'
import { FakeProgressBar, formatBytes } from "./fakes"; import { FakeProgressBar, formatBytes } from './fakes'
const FAKE_TAGS = ["landscape", "sunset", "outdoors", "no_humans", "ocean", "cloudy_sky"]; const FAKE_TAGS = ['landscape', 'sunset', 'outdoors', 'no_humans', 'ocean', 'cloudy_sky']
// Prefer the current file's byte fraction (the 1.3 GB model dominates); fall // Prefer the current file's byte fraction (the 1.3 GB model dominates); fall
// back to the coarse step count; null renders an indeterminate bar. // back to the coarse step count; null renders an indeterminate bar.
function taggerDownloadFraction(progress: TaggerModelProgress | null): number | null { function taggerDownloadFraction(progress: TaggerModelProgress | null): number | null {
if (!progress) return null; if (!progress) return null
if (progress.downloaded_bytes != null && progress.total_bytes != null && progress.total_bytes > 0) { if (
return progress.downloaded_bytes / progress.total_bytes; progress.downloaded_bytes != null &&
progress.total_bytes != null &&
progress.total_bytes > 0
) {
return progress.downloaded_bytes / progress.total_bytes
} }
if (progress.total_files > 0) return progress.completed_files / progress.total_files; if (progress.total_files > 0) return progress.completed_files / progress.total_files
return null; return null
} }
function TaggerModelChoice({ model, current, disabled, onSelect }: { function TaggerModelChoice({
model: TaggerModel; model,
current: TaggerModel; current,
disabled: boolean; disabled,
onSelect: (model: TaggerModel) => void; onSelect,
}: {
model: TaggerModel
current: TaggerModel
disabled: boolean
onSelect: (model: TaggerModel) => void
}) { }) {
const active = model === current; const active = model === current
return ( return (
<button <button
type="button" type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${ className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
active active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700" ? 'light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 border-emerald-400/35 bg-emerald-500/15 text-emerald-200'
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white" : 'light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200'
}`} }`}
disabled={disabled} disabled={disabled}
onClick={() => onSelect(model)} onClick={() => onSelect(model)}
> >
{TAGGER_MODELS[model].tab} {TAGGER_MODELS[model].tab}
</button> </button>
); )
} }
export function StepAiFeatures() { export function StepAiFeatures() {
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus); const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus)
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing); const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing)
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress); const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress)
const taggerModelError = useGalleryStore((s) => s.taggerModelError); const taggerModelError = useGalleryStore((s) => s.taggerModelError)
const taggerModel = useGalleryStore((s) => s.taggerModel); const taggerModel = useGalleryStore((s) => s.taggerModel)
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel); const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel)
const loadTaggerModel = useGalleryStore((s) => s.loadTaggerModel); const loadTaggerModel = useGalleryStore((s) => s.loadTaggerModel)
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus); const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus)
const setTaggerModel = useGalleryStore((s) => s.setTaggerModel); const setTaggerModel = useGalleryStore((s) => s.setTaggerModel)
useEffect(() => { useEffect(() => {
void loadTaggerModel(); void loadTaggerModel()
void loadTaggerModelStatus(); void loadTaggerModelStatus()
}, [loadTaggerModel, loadTaggerModelStatus]); }, [loadTaggerModel, loadTaggerModelStatus])
const taggerReady = taggerModelStatus?.ready ?? false; const taggerReady = taggerModelStatus?.ready ?? false
const taggerStatusLabel = taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"; const taggerStatusLabel = taggerReady
? 'Installed'
: taggerModelPreparing
? 'Preparing'
: 'Not installed'
return ( return (
<div> <div>
@@ -65,26 +78,31 @@ export function StepAiFeatures() {
itself up automatically; AI tagging is optional and only downloads if you want it. itself up automatically; AI tagging is optional and only downloads if you want it.
</p> </p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">AI tagging — optional</h4> <h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70"> AI tagging — optional
</h4>
<div className="light-theme:divide-gray-300/70 mt-1 divide-y divide-white/[0.05]">
<div className="py-4"> <div className="py-4">
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-white">Automatic tags for every image</p> <p className="text-sm text-white">Automatic tags for every image</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500"> <p className="mt-1 text-xs leading-relaxed text-gray-500">
The AI tagger model labels images so you can search with{" "} The AI tagger model labels images so you can search with{' '}
<code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> — tags look like: <code className="light-theme:bg-gray-800 light-theme:text-gray-100 rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200">
/t
</code>{' '}
— tags look like:
</p> </p>
<div className="mt-3 inline-flex max-w-full flex-wrap items-center gap-1 rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80"> <div className="light-theme:border-gray-300/80 mt-3 inline-flex max-w-full flex-wrap items-center gap-1 rounded-lg border border-white/[0.07] p-0.5">
{(["wd", "joytag"] as const).map((model) => ( {(['wd', 'joytag'] as const).map((model) => (
<TaggerModelChoice <TaggerModelChoice
key={model} key={model}
model={model} model={model}
current={taggerModel} current={taggerModel}
disabled={taggerModelPreparing} disabled={taggerModelPreparing}
onSelect={(nextModel) => { onSelect={(nextModel) => {
if (nextModel === taggerModel) return; if (nextModel === taggerModel) return
void setTaggerModel(nextModel); void setTaggerModel(nextModel)
}} }}
/> />
))} ))}
@@ -97,7 +115,10 @@ export function StepAiFeatures() {
</p> </p>
<span className="mt-2 flex flex-wrap gap-1.5"> <span className="mt-2 flex flex-wrap gap-1.5">
{FAKE_TAGS.map((tag) => ( {FAKE_TAGS.map((tag) => (
<span key={tag} className="rounded-md border border-white/10 bg-gray-900/50 px-2 py-0.5 text-[11px] text-gray-400 light-theme:border-gray-300/70 light-theme:bg-gray-900 light-theme:text-gray-600"> <span
key={tag}
className="light-theme:border-gray-300/70 light-theme:bg-gray-900 light-theme:text-gray-600 rounded-md border border-white/10 bg-gray-900/50 px-2 py-0.5 text-[11px] text-gray-400"
>
{tag} {tag}
</span> </span>
))} ))}
@@ -105,16 +126,16 @@ export function StepAiFeatures() {
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{taggerReady ? ( {taggerReady ? (
<span className="inline-flex rounded-md border border-emerald-400/25 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700"> <span className="light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700 inline-flex rounded-md border border-emerald-400/25 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
Installed Installed
</span> </span>
) : ( ) : (
<button <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 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white 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 prepareTaggerModel()}
disabled={taggerModelPreparing} disabled={taggerModelPreparing}
> >
{taggerModelPreparing ? "Downloading..." : "Download now"} {taggerModelPreparing ? 'Downloading...' : 'Download now'}
</button> </button>
)} )}
</div> </div>
@@ -123,40 +144,49 @@ export function StepAiFeatures() {
<div className="mt-3"> <div className="mt-3">
<FakeProgressBar fraction={taggerDownloadFraction(taggerModelProgress)} /> <FakeProgressBar fraction={taggerDownloadFraction(taggerModelProgress)} />
<div className="mt-1.5 flex items-center justify-between gap-3 text-[11px] text-gray-600"> <div className="mt-1.5 flex items-center justify-between gap-3 text-[11px] text-gray-600">
<span className="truncate">{taggerModelProgress?.current_file ?? "Preparing..."}</span> <span className="truncate">
{taggerModelProgress?.downloaded_bytes != null && taggerModelProgress.total_bytes != null ? ( {taggerModelProgress?.current_file ?? 'Preparing...'}
</span>
{taggerModelProgress?.downloaded_bytes != null &&
taggerModelProgress.total_bytes != null ? (
<span className="shrink-0 tabular-nums"> <span className="shrink-0 tabular-nums">
{formatBytes(taggerModelProgress.downloaded_bytes)} / {formatBytes(taggerModelProgress.total_bytes)} {formatBytes(taggerModelProgress.downloaded_bytes)} /{' '}
{formatBytes(taggerModelProgress.total_bytes)}
</span> </span>
) : null} ) : null}
</div> </div>
</div> </div>
) : null} ) : null}
{!taggerModelPreparing && taggerModelError ? ( {!taggerModelPreparing && taggerModelError ? (
<p className="mt-2 text-xs leading-relaxed text-amber-300/90 light-theme:text-amber-700"> <p className="light-theme:text-amber-700 mt-2 text-xs leading-relaxed text-amber-300/90">
Download failed: {taggerModelError} Download failed: {taggerModelError}
</p> </p>
) : null} ) : null}
</div> </div>
</div> </div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Semantic search & similarity — built in</h4> <h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70"> Semantic search & similarity — built in
</h4>
<div className="light-theme:divide-gray-300/70 mt-1 divide-y divide-white/[0.05]">
<div className="py-4"> <div className="py-4">
<p className="text-sm text-white">Search by meaning, find look-alikes</p> <p className="text-sm text-white">Search by meaning, find look-alikes</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500"> <p className="mt-1 text-xs leading-relaxed text-gray-500">
Powers <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/s</code> search, Powers{' '}
"find similar", and the Explore view, so it's part of the standard pipeline: the CLIP model <code className="light-theme:bg-gray-800 light-theme:text-gray-100 rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200">
(~580 MB) downloads automatically the first time embeddings run. Nothing to do you'll see it /s
in the background-tasks bar. </code>{' '}
search, "find similar", and the Explore view, so it's part of the standard pipeline: the
CLIP model (~580 MB) downloads automatically the first time embeddings run. Nothing to
do you'll see it in the background-tasks bar.
</p> </p>
</div> </div>
</div> </div>
<p className="mt-5 text-xs leading-relaxed text-gray-500"> <p className="mt-5 text-xs leading-relaxed text-gray-500">
Semantic search and similarity are part of the standard pipeline; AI tagging stays optional and Semantic search and similarity are part of the standard pipeline; AI tagging stays optional
can be downloaded any time from Settings. and can be downloaded any time from Settings.
</p> </p>
</div> </div>
); )
} }
@@ -1,40 +1,41 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { FakeTile, ReplayButton } from "./fakes"; import { FakeTile, ReplayButton } from './fakes'
const REVEAL_MS = 280; const REVEAL_MS = 280
// Two rows (cols-4) keeps the explainer text visible without scrolling. // Two rows (cols-4) keeps the explainer text visible without scrolling.
const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = [ const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = [
{}, {},
{ favorite: true }, { favorite: true },
{}, {},
{ duration: "1:24" }, { duration: '1:24' },
{ rating: 5 }, { rating: 5 },
{ favorite: true, rating: 3 }, { favorite: true, rating: 3 },
{ duration: "0:09" }, { duration: '0:09' },
{}, {},
]; ]
const TILE_COUNT = TILE_PROPS.length; const TILE_COUNT = TILE_PROPS.length
/// Placeholder tiles "loading in" once (skeleton → image), then stopping fully /// Placeholder tiles "loading in" once (skeleton → image), then stopping fully
/// revealed with a replay control. /// revealed with a replay control.
export function StepGalleryPreview() { export function StepGalleryPreview() {
const [revealed, setRevealed] = useState(0); const [revealed, setRevealed] = useState(0)
useEffect(() => { useEffect(() => {
if (revealed >= TILE_COUNT) return; if (revealed >= TILE_COUNT) return
const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS); const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS)
return () => clearTimeout(timer); return () => clearTimeout(timer)
}, [revealed]); }, [revealed])
const finished = revealed >= TILE_COUNT; const finished = revealed >= TILE_COUNT
return ( return (
<div> <div>
<p className="text-sm leading-relaxed text-gray-300"> <p className="text-sm leading-relaxed text-gray-300">
The gallery is a virtualized grid it stays fast with hundreds of thousands of items. Tiles The gallery is a virtualized grid it stays fast with hundreds of thousands of items. Tiles
carry your favorites, star ratings, and video durations; hover for filename and quick actions. carry your favorites, star ratings, and video durations; hover for filename and quick
actions.
</p> </p>
<div className="media-dark-surface mt-5 grid grid-cols-4 gap-1.5"> <div className="media-dark-surface mt-5 grid grid-cols-4 gap-1.5">
@@ -44,18 +45,18 @@ export function StepGalleryPreview() {
</div> </div>
<div className="mt-6 flex items-start justify-between gap-4"> <div className="mt-6 flex items-start justify-between gap-4">
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500 light-theme:divide-gray-300/70"> <div className="light-theme:divide-gray-300/70 divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
<p className="pb-2.5"> <p className="pb-2.5">
<span className="text-gray-300">Click any tile</span> to open the lightbox keyboard navigation, <span className="text-gray-300">Click any tile</span> to open the lightbox keyboard
zoom, inline tag editing, ratings, and a full video player. navigation, zoom, inline tag editing, ratings, and a full video player.
</p> </p>
<p className="pt-2.5"> <p className="pt-2.5">
<span className="text-gray-300">The toolbar</span> filters by type, favorites, or rating, sorts by <span className="text-gray-300">The toolbar</span> filters by type, favorites, or
date/name/size, and switches grid density. rating, sorts by date/name/size, and switches grid density.
</p> </p>
</div> </div>
{finished ? <ReplayButton onClick={() => setRevealed(0)} /> : null} {finished ? <ReplayButton onClick={() => setRevealed(0)} /> : null}
</div> </div>
</div> </div>
); )
} }
+44 -34
View File
@@ -1,47 +1,47 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { FakeProgressBar, FakeStageTag, ReplayButton } from "./fakes"; import { FakeProgressBar, FakeStageTag, ReplayButton } from './fakes'
const STAGES = ["Thumbnails", "Metadata", "Embeddings", "Tags"] as const; const STAGES = ['Thumbnails', 'Metadata', 'Embeddings', 'Tags'] as const
const STAGE_TOTAL = 128; const STAGE_TOTAL = 128
const TICK_MS = 80; const TICK_MS = 80
const STEP_PER_TICK = 8; const STEP_PER_TICK = 8
/// A one-shot fake of the background-tasks bar: each stage drains in order, /// A one-shot fake of the background-tasks bar: each stage drains in order,
/// then it stops at "all done" with a replay control. /// then it stops at "all done" with a replay control.
export function StepPipeline() { export function StepPipeline() {
const [stageIndex, setStageIndex] = useState(0); const [stageIndex, setStageIndex] = useState(0)
const [filled, setFilled] = useState(0); const [filled, setFilled] = useState(0)
const finished = stageIndex >= STAGES.length; const finished = stageIndex >= STAGES.length
useEffect(() => { useEffect(() => {
if (finished) return; if (finished) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
// Read from the closure and call setters directly — nesting one setter // Read from the closure and call setters directly — nesting one setter
// inside another's updater double-advances under React StrictMode. // inside another's updater double-advances under React StrictMode.
if (filled + STEP_PER_TICK < STAGE_TOTAL) { if (filled + STEP_PER_TICK < STAGE_TOTAL) {
setFilled(filled + STEP_PER_TICK); setFilled(filled + STEP_PER_TICK)
} else { } else {
setStageIndex(stageIndex + 1); setStageIndex(stageIndex + 1)
setFilled(0); setFilled(0)
} }
}, TICK_MS); }, TICK_MS)
return () => clearTimeout(timer); return () => clearTimeout(timer)
}, [filled, stageIndex, finished]); }, [filled, stageIndex, finished])
const replay = () => { const replay = () => {
setStageIndex(0); setStageIndex(0)
setFilled(0); setFilled(0)
}; }
const remaining = STAGE_TOTAL - filled; const remaining = STAGE_TOTAL - filled
return ( return (
<div> <div>
<p className="text-sm leading-relaxed text-gray-300"> <p className="text-sm leading-relaxed text-gray-300">
After indexing, Phokus works through a strict pipeline: thumbnails first, then video metadata, After indexing, Phokus works through a strict pipeline: thumbnails first, then video
then visual embeddings (for similarity and semantic search), then AI tags. One stage at a time, metadata, then visual embeddings (for similarity and semantic search), then AI tags. One
so each runs at full speed. stage at a time, so each runs at full speed.
</p> </p>
<p className="mt-5 text-xs text-gray-500"> <p className="mt-5 text-xs text-gray-500">
@@ -49,41 +49,51 @@ export function StepPipeline() {
</p> </p>
{/* Fake BackgroundTasks slim bar */} {/* Fake BackgroundTasks slim bar */}
<div className="mt-3 rounded-lg border border-white/[0.07] bg-gray-900/30 px-4 py-3 light-theme:border-gray-300/70 light-theme:bg-gray-900"> <div className="light-theme:border-gray-300/70 light-theme:bg-gray-900 mt-3 rounded-lg border border-white/[0.07] bg-gray-900/30 px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="relative flex h-1.5 w-1.5 shrink-0"> <span className="relative flex h-1.5 w-1.5 shrink-0">
{!finished ? ( {!finished ? (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-60" /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-60" />
) : null} ) : null}
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-blue-400"}`} /> <span
className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? 'bg-emerald-400' : 'bg-blue-400'}`}
/>
</span>
<span className="light-theme:text-gray-500 text-[13px] font-medium text-white/60">
Holiday Photos
</span> </span>
<span className="text-[13px] font-medium text-white/60 light-theme:text-gray-500">Holiday Photos</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{STAGES.map((stage, i) => ( {STAGES.map((stage, i) => (
<FakeStageTag <FakeStageTag
key={stage} key={stage}
label={!finished && i === stageIndex ? `${stage} · ${remaining}` : stage} label={!finished && i === stageIndex ? `${stage} · ${remaining}` : stage}
state={finished || i < stageIndex ? "done" : i === stageIndex ? "active" : "waiting"} state={
finished || i < stageIndex ? 'done' : i === stageIndex ? 'active' : 'waiting'
}
/> />
))} ))}
</div> </div>
<FakeProgressBar fraction={finished ? 1 : filled / STAGE_TOTAL} className="ml-auto w-24" /> <FakeProgressBar
fraction={finished ? 1 : filled / STAGE_TOTAL}
className="ml-auto w-24"
/>
</div> </div>
</div> </div>
<div className="mt-6 flex items-start justify-between gap-4"> <div className="mt-6 flex items-start justify-between gap-4">
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500 light-theme:divide-gray-300/70"> <div className="light-theme:divide-gray-300/70 divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
<p className="pb-2.5"> <p className="pb-2.5">
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever you like <span className="text-gray-300">It's all interruptible.</span> Close the app whenever
the queue picks up where it left off next launch. you like the queue picks up where it left off next launch.
</p> </p>
<p className="pt-2.5"> <p className="pt-2.5">
<span className="text-gray-300">Per-folder control.</span> Right-click a folder in the sidebar to <span className="text-gray-300">Per-folder control.</span> Right-click a folder in the
pause its background work, and click the bar itself to expand a detailed per-folder view. sidebar to pause its background work, and click the bar itself to expand a detailed
per-folder view.
</p> </p>
</div> </div>
{finished ? <ReplayButton onClick={replay} /> : null} {finished ? <ReplayButton onClick={replay} /> : null}
</div> </div>
</div> </div>
); )
} }
+59 -41
View File
@@ -1,83 +1,101 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { ReplayButton, SEARCH_RESULTS } from "./fakes"; import { ReplayButton, SEARCH_RESULTS } from './fakes'
const DEMOS = [ const DEMOS = [
{ {
query: "beach-day_042.jpg", query: 'beach-day_042.jpg',
mode: "Filename", mode: 'Filename',
description: "Plain text matches file names — the default, instant search. One name, one file.", description: 'Plain text matches file names — the default, instant search. One name, one file.',
results: SEARCH_RESULTS.filename, results: SEARCH_RESULTS.filename,
}, },
{ {
query: "/s golden sunset over water", query: '/s golden sunset over water',
mode: "Semantic", mode: 'Semantic',
description: "Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.", description:
"Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.",
results: SEARCH_RESULTS.semantic, results: SEARCH_RESULTS.semantic,
}, },
{ {
query: "/t landscape", query: '/t landscape',
mode: "Tags", mode: 'Tags',
description: "Search by AI or manual tags. Autocomplete suggests tags as you type.", description: 'Search by AI or manual tags. Autocomplete suggests tags as you type.',
results: SEARCH_RESULTS.tags, results: SEARCH_RESULTS.tags,
}, },
] as const; ] as const
const TYPE_MS = 55; const TYPE_MS = 55
const HOLD_MS = 2600; const HOLD_MS = 2600
/// Types each demo query, shows matching results, advances through all three /// Types each demo query, shows matching results, advances through all three
/// modes once, then stops on the last with a replay control. /// modes once, then stops on the last with a replay control.
export function StepSearchDemo() { export function StepSearchDemo() {
const [demoIndex, setDemoIndex] = useState(0); const [demoIndex, setDemoIndex] = useState(0)
const [typed, setTyped] = useState(0); const [typed, setTyped] = useState(0)
const [finished, setFinished] = useState(false); const [finished, setFinished] = useState(false)
const demo = DEMOS[demoIndex]; const demo = DEMOS[demoIndex]
const isLastDemo = demoIndex === DEMOS.length - 1; const isLastDemo = demoIndex === DEMOS.length - 1
useEffect(() => { useEffect(() => {
if (finished) return; if (finished) return
if (typed < demo.query.length) { if (typed < demo.query.length) {
const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS); const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
// Fully typed: hold, then advance — or finish on the last demo. // Fully typed: hold, then advance — or finish on the last demo.
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (isLastDemo) { if (isLastDemo) {
setFinished(true); setFinished(true)
} else { } else {
setDemoIndex((i) => i + 1); setDemoIndex((i) => i + 1)
setTyped(0); setTyped(0)
} }
}, HOLD_MS); }, HOLD_MS)
return () => clearTimeout(timer); return () => clearTimeout(timer)
}, [typed, demo.query.length, isLastDemo, finished]); }, [typed, demo.query.length, isLastDemo, finished])
const replay = () => { const replay = () => {
setDemoIndex(0); setDemoIndex(0)
setTyped(0); setTyped(0)
setFinished(false); setFinished(false)
}; }
const fullyTyped = typed >= demo.query.length; const fullyTyped = typed >= demo.query.length
return ( return (
<div> <div>
<p className="text-sm leading-relaxed text-gray-300"> <p className="text-sm leading-relaxed text-gray-300">
One search bar, three modes picked by prefix. No prefix searches filenames, <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/s</code> searches One search bar, three modes picked by prefix. No prefix searches filenames,{' '}
by meaning, <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> searches tags. <code className="light-theme:bg-gray-800 light-theme:text-gray-100 rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200">
/s
</code>{' '}
searches by meaning,{' '}
<code className="light-theme:bg-gray-800 light-theme:text-gray-100 rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200">
/t
</code>{' '}
searches tags.
</p> </p>
{/* Mock search bar */} {/* Mock search bar */}
<div className="mt-5 flex items-center gap-2.5 rounded-lg border border-white/10 bg-gray-900/50 px-3.5 py-2.5 light-theme:border-gray-300/70 light-theme:bg-gray-900"> <div className="light-theme:border-gray-300/70 light-theme:bg-gray-900 mt-5 flex items-center gap-2.5 rounded-lg border border-white/10 bg-gray-900/50 px-3.5 py-2.5">
<svg className="h-4 w-4 shrink-0 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> className="h-4 w-4 shrink-0 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg> </svg>
<span className="text-sm text-white"> <span className="text-sm text-white">
{demo.query.slice(0, typed)} {demo.query.slice(0, typed)}
{!finished ? <span className="animate-pulse text-gray-500">|</span> : null} {!finished ? <span className="animate-pulse text-gray-500">|</span> : null}
</span> </span>
<span className="ml-auto shrink-0 rounded-md border border-sky-400/25 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700"> <span className="light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700 ml-auto shrink-0 rounded-md border border-sky-400/25 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-300">
{demo.mode} {demo.mode}
</span> </span>
</div> </div>
@@ -90,7 +108,7 @@ export function StepSearchDemo() {
demo's visible state. */} demo's visible state. */}
<div <div
key={demoIndex} key={demoIndex}
className={`media-dark-surface mt-3 flex flex-wrap justify-center gap-1.5 transition-opacity duration-300 ${fullyTyped ? "opacity-100" : "opacity-0"}`} className={`media-dark-surface mt-3 flex flex-wrap justify-center gap-1.5 transition-opacity duration-300 ${fullyTyped ? 'opacity-100' : 'opacity-0'}`}
> >
{demo.results.map((src, i) => ( {demo.results.map((src, i) => (
<div key={i} className="aspect-square w-36 overflow-hidden rounded-xl bg-white/[0.04]"> <div key={i} className="aspect-square w-36 overflow-hidden rounded-xl bg-white/[0.04]">
@@ -104,5 +122,5 @@ export function StepSearchDemo() {
{finished ? <ReplayButton onClick={replay} /> : null} {finished ? <ReplayButton onClick={replay} /> : null}
</div> </div>
</div> </div>
); )
} }
+24 -19
View File
@@ -1,4 +1,4 @@
import { PhokusMark } from "../PhokusMark"; import { PhokusMark } from '../PhokusMark'
// Closing step. Introduces the app mark (the aperture in the title bar) and, // Closing step. Introduces the app mark (the aperture in the title bar) and,
// since that same mark doubles as the update indicator, explains how updates // since that same mark doubles as the update indicator, explains how updates
@@ -12,23 +12,23 @@ export function StepUpdates() {
have to go looking for one. have to go looking for one.
</p> </p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400"> <h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
When an update is ready When an update is ready
</h4> </h4>
<div className="mt-1 py-4"> <div className="mt-1 py-4">
<p className="text-sm text-white">The mark in the title bar lights up</p> <p className="text-sm text-white">The mark in the title bar lights up</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500"> <p className="mt-1 text-xs leading-relaxed text-gray-500">
That aperture in the top-left corner is Phokus. When a new version is waiting, its focal point That aperture in the top-left corner is Phokus. When a new version is waiting, its focal
glows amber — click it to update and relaunch. Nothing installs on its own; you're always in point glows amber — click it to update and relaunch. Nothing installs on its own; you're
control. always in control.
</p> </p>
{/* Mini app-window mockup: the lit mark shown in a real title bar. */} {/* Mini app-window mockup: the lit mark shown in a real title bar. */}
<div className="mt-3 overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-lg light-theme:border-gray-300/70"> <div className="light-theme:border-gray-300/70 mt-3 overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-lg">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2 light-theme:border-gray-300/70"> <div className="light-theme:border-gray-300/70 flex items-center gap-2 border-b border-white/[0.06] px-3 py-2">
<div className="relative flex h-6 w-6 items-center justify-center rounded-md bg-gray-900 text-gray-300 light-theme:bg-gray-800"> <div className="light-theme:bg-gray-800 relative flex h-6 w-6 items-center justify-center rounded-md bg-gray-900 text-gray-300">
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/30 blur-[3px]" /> <span className="pointer-events-none absolute top-1/2 left-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/30 blur-[3px]" />
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/55 animate-ping" /> <span className="pointer-events-none absolute top-1/2 left-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 animate-ping rounded-full bg-amber-400/55" />
<PhokusMark className="relative h-[18px] w-[18px]" dotClassName="fill-amber-400" /> <PhokusMark className="relative h-[18px] w-[18px]" dotClassName="fill-amber-400" />
</div> </div>
<span className="text-xs font-semibold tracking-wide text-gray-300">Phokus</span> <span className="text-xs font-semibold tracking-wide text-gray-300">Phokus</span>
@@ -41,14 +41,19 @@ export function StepUpdates() {
<rect x="0.5" y="0.5" width="9" height="9" rx="1.5" stroke="currentColor" /> <rect x="0.5" y="0.5" width="9" height="9" rx="1.5" stroke="currentColor" />
</svg> </svg>
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"> <svg width="9" height="9" viewBox="0 0 10 10" fill="none">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" /> <path
d="M1 1L9 9M9 1L1 9"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
</svg> </svg>
</div> </div>
</div> </div>
{/* Ghosted window body, just enough to read as the app. */} {/* Ghosted window body, just enough to read as the app. */}
<div className="flex h-[72px] bg-gray-900/30"> <div className="flex h-[72px] bg-gray-900/30">
<div className="w-14 shrink-0 border-r border-white/[0.05] p-2.5 light-theme:border-gray-300/70"> <div className="light-theme:border-gray-300/70 w-14 shrink-0 border-r border-white/[0.05] p-2.5">
<div className="h-1.5 w-full rounded bg-gray-800" /> <div className="h-1.5 w-full rounded bg-gray-800" />
<div className="mt-2 h-1.5 w-3/4 rounded bg-gray-900" /> <div className="mt-2 h-1.5 w-3/4 rounded bg-gray-900" />
<div className="mt-2 h-1.5 w-3/4 rounded bg-gray-900" /> <div className="mt-2 h-1.5 w-3/4 rounded bg-gray-900" />
@@ -65,22 +70,22 @@ export function StepUpdates() {
</div> </div>
</div> </div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400"> <h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
Prefer to manage it yourself? Prefer to manage it yourself?
</h4> </h4>
<div className="mt-1 py-4"> <div className="mt-1 py-4">
<p className="text-sm text-white">Check any time from Settings</p> <p className="text-sm text-white">Check any time from Settings</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500"> <p className="mt-1 text-xs leading-relaxed text-gray-500">
Settings General shows your current version with a manual{" "} Settings General shows your current version with a manual{' '}
<span className="text-gray-300">Check for updates</span> button, so you can update on your own <span className="text-gray-300">Check for updates</span> button, so you can update on your
schedule. own schedule.
</p> </p>
</div> </div>
<p className="mt-5 text-xs leading-relaxed text-gray-500"> <p className="mt-5 text-xs leading-relaxed text-gray-500">
That's the tour. Add folders from the sidebar, and revisit any of this from Settings including That's the tour. Add folders from the sidebar, and revisit any of this from Settings
re-running this tour. including re-running this tour.
</p> </p>
</div> </div>
); )
} }
+27 -13
View File
@@ -1,6 +1,14 @@
import { FakeTile, tileGradient } from "./fakes"; import { FakeTile, tileGradient } from './fakes'
function ViewRow({ title, description, preview }: { title: string; description: string; preview: React.ReactNode }) { function ViewRow({
title,
description,
preview,
}: {
title: string
description: string
preview: React.ReactNode
}) {
return ( return (
<div className="flex items-center justify-between gap-6 py-4"> <div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0"> <div className="min-w-0">
@@ -9,7 +17,7 @@ function ViewRow({ title, description, preview }: { title: string; description:
</div> </div>
<div className="shrink-0">{preview}</div> <div className="shrink-0">{preview}</div>
</div> </div>
); )
} }
function ExplorePreview() { function ExplorePreview() {
@@ -20,7 +28,7 @@ function ExplorePreview() {
{ size: 18, x: 86, y: 22, i: 4 }, { size: 18, x: 86, y: 22, i: 4 },
{ size: 26, x: 30, y: 36, i: 1 }, { size: 26, x: 30, y: 36, i: 1 },
{ size: 16, x: 70, y: 44, i: 6 }, { size: 16, x: 70, y: 44, i: 6 },
]; ]
return ( return (
<div className="media-dark-surface relative h-[72px] w-32 overflow-hidden rounded-lg border border-white/[0.07] bg-white/[0.02]"> <div className="media-dark-surface relative h-[72px] w-32 overflow-hidden rounded-lg border border-white/[0.07] bg-white/[0.02]">
{blobs.map((blob, idx) => ( {blobs.map((blob, idx) => (
@@ -31,7 +39,7 @@ function ExplorePreview() {
/> />
))} ))}
</div> </div>
); )
} }
function TimelinePreview() { function TimelinePreview() {
@@ -39,14 +47,17 @@ function TimelinePreview() {
<div className="media-dark-surface flex h-[72px] w-32 flex-col justify-center gap-2 rounded-lg border border-white/[0.07] bg-white/[0.02] px-3"> <div className="media-dark-surface flex h-[72px] w-32 flex-col justify-center gap-2 rounded-lg border border-white/[0.07] bg-white/[0.02] px-3">
{[2024, 2023].map((year, row) => ( {[2024, 2023].map((year, row) => (
<div key={year} className="flex items-center gap-1.5"> <div key={year} className="flex items-center gap-1.5">
<span className="w-7 text-[9px] tabular-nums text-gray-600">{year}</span> <span className="w-7 text-[9px] text-gray-600 tabular-nums">{year}</span>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`h-4 w-4 rounded-sm bg-gradient-to-br ${tileGradient(row * 3 + i)}`} /> <div
key={i}
className={`h-4 w-4 rounded-sm bg-gradient-to-br ${tileGradient(row * 3 + i)}`}
/>
))} ))}
</div> </div>
))} ))}
</div> </div>
); )
} }
function DuplicatesPreview() { function DuplicatesPreview() {
@@ -57,10 +68,12 @@ function DuplicatesPreview() {
</div> </div>
<div className="relative w-10"> <div className="relative w-10">
<FakeTile index={3} className="rounded-md" /> <FakeTile index={3} className="rounded-md" />
<span className="absolute -right-1 -top-1 rounded-full bg-amber-500/90 px-1 text-[8px] font-semibold text-black">2×</span> <span className="absolute -top-1 -right-1 rounded-full bg-amber-500/90 px-1 text-[8px] font-semibold text-black">
2×
</span>
</div> </div>
</div> </div>
); )
} }
export function StepViews() { export function StepViews() {
@@ -69,7 +82,7 @@ export function StepViews() {
<p className="text-sm leading-relaxed text-gray-300"> <p className="text-sm leading-relaxed text-gray-300">
Beyond the main grid, the sidebar switches between three more ways to look at your library: Beyond the main grid, the sidebar switches between three more ways to look at your library:
</p> </p>
<div className="mt-3 divide-y divide-white/[0.05] light-theme:divide-gray-300/70"> <div className="light-theme:divide-gray-300/70 mt-3 divide-y divide-white/[0.05]">
<ViewRow <ViewRow
title="Explore" title="Explore"
description="A visual cluster map and tag cloud — browse by theme instead of folder, and jump into any cluster." description="A visual cluster map and tag cloud — browse by theme instead of folder, and jump into any cluster."
@@ -87,8 +100,9 @@ export function StepViews() {
/> />
</div> </div>
<p className="mt-4 text-xs leading-relaxed text-gray-500"> <p className="mt-4 text-xs leading-relaxed text-gray-500">
Each view can be scoped to a single folder from its header no need to bounce through the sidebar. Each view can be scoped to a single folder from its header no need to bounce through the
sidebar.
</p> </p>
</div> </div>
); )
} }
+113 -74
View File
@@ -1,149 +1,171 @@
import { AppTheme, useGalleryStore } from "../../store"; import { AppTheme, useGalleryStore } from '../../store'
import { FakeProgressBar, formatBytes } from "./fakes"; import { FakeProgressBar, formatBytes } from './fakes'
const THEME_OPTIONS: { const THEME_OPTIONS: {
value: AppTheme; value: AppTheme
name: string; name: string
colors: { colors: {
background: string; background: string
surface: string; surface: string
text: string; text: string
muted: string; muted: string
accent: string; accent: string
}; }
}[] = [ }[] = [
{ {
value: "phokus", value: 'phokus',
name: "Phokus", name: 'Phokus',
colors: { colors: {
background: "#030712", background: '#030712',
surface: "#111827", surface: '#111827',
text: "#f9fafb", text: '#f9fafb',
muted: "#6b7280", muted: '#6b7280',
accent: "#10b981", accent: '#10b981',
}, },
}, },
{ {
value: "conventional-dark", value: 'conventional-dark',
name: "Conventional Dark", name: 'Conventional Dark',
colors: { colors: {
background: "#1f1f1f", background: '#1f1f1f',
surface: "#303030", surface: '#303030',
text: "#a3a3a3", text: '#a3a3a3',
muted: "#666666", muted: '#666666',
accent: "#10b981", accent: '#10b981',
}, },
}, },
{ {
value: "subtle-light", value: 'subtle-light',
name: "Subtle Light", name: 'Subtle Light',
colors: { colors: {
background: "#e9e7e2", background: '#e9e7e2',
surface: "#dedbd4", surface: '#dedbd4',
text: "#18202c", text: '#18202c',
muted: "#817b72", muted: '#817b72',
accent: "#059669", accent: '#059669',
}, },
}, },
]; ]
export function FfmpegStatusRow() { export function FfmpegStatusRow() {
const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus); const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus)
const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress); const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress)
const ffmpegError = useGalleryStore((s) => s.ffmpegError); const ffmpegError = useGalleryStore((s) => s.ffmpegError)
const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload); const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload)
if (ffmpegStatus === "installed") { if (ffmpegStatus === 'installed') {
return ( return (
<div className="flex items-center gap-2.5 py-3"> <div className="flex items-center gap-2.5 py-3">
<svg className="h-4 w-4 shrink-0 text-emerald-400 light-theme:text-emerald-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="light-theme:text-emerald-700 h-4 w-4 shrink-0 text-emerald-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<div> <div>
<p className="text-sm text-white">Media engine ready</p> <p className="text-sm text-white">Media engine ready</p>
<p className="text-xs text-gray-500">FFmpeg is installed video thumbnails and metadata are available.</p> <p className="text-xs text-gray-500">
FFmpeg is installed video thumbnails and metadata are available.
</p>
</div> </div>
</div> </div>
); )
} }
if (ffmpegStatus === "error") { if (ffmpegStatus === 'error') {
return ( return (
<div className="py-3"> <div className="py-3">
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-white">Media engine download failed</p> <p className="text-sm text-white">Media engine download failed</p>
<p className="mt-1 text-xs leading-relaxed text-amber-300/90 light-theme:text-amber-700">{ffmpegError}</p> <p className="light-theme:text-amber-700 mt-1 text-xs leading-relaxed text-amber-300/90">
{ffmpegError}
</p>
<p className="mt-1.5 text-xs leading-relaxed text-gray-500"> <p className="mt-1.5 text-xs leading-relaxed text-gray-500">
You can keep going photos work without it. Video thumbnails and metadata will start You can keep going photos work without it. Video thumbnails and metadata will start
automatically once the download succeeds. automatically once the download succeeds.
</p> </p>
</div> </div>
<button <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 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white 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"
onClick={() => void retryFfmpegDownload()} onClick={() => void retryFfmpegDownload()}
> >
Retry download Retry download
</button> </button>
</div> </div>
</div> </div>
); )
} }
const fraction = const fraction =
ffmpegStatus === "downloading" && ffmpegProgress && ffmpegProgress.total_bytes > 0 ffmpegStatus === 'downloading' && ffmpegProgress && ffmpegProgress.total_bytes > 0
? ffmpegProgress.downloaded_bytes / ffmpegProgress.total_bytes ? ffmpegProgress.downloaded_bytes / ffmpegProgress.total_bytes
: null; : null
return ( return (
<div className="py-3"> <div className="py-3">
<div className="flex items-baseline justify-between gap-6"> <div className="flex items-baseline justify-between gap-6">
<p className="text-sm text-white"> <p className="text-sm text-white">
{ffmpegStatus === "unpacking" ? "Unpacking media engine..." : "Downloading media engine (FFmpeg)..."} {ffmpegStatus === 'unpacking'
? 'Unpacking media engine...'
: 'Downloading media engine (FFmpeg)...'}
</p> </p>
{ffmpegProgress ? ( {ffmpegProgress ? (
<span className="text-[11px] tabular-nums text-gray-500"> <span className="text-[11px] text-gray-500 tabular-nums">
{formatBytes(ffmpegProgress.downloaded_bytes)} / {formatBytes(ffmpegProgress.total_bytes)} {formatBytes(ffmpegProgress.downloaded_bytes)} /{' '}
{formatBytes(ffmpegProgress.total_bytes)}
</span> </span>
) : null} ) : null}
</div> </div>
<FakeProgressBar fraction={ffmpegStatus === "unpacking" ? null : fraction} className="mt-2.5" /> <FakeProgressBar
fraction={ffmpegStatus === 'unpacking' ? null : fraction}
className="mt-2.5"
/>
<p className="mt-2 text-xs leading-relaxed text-gray-500"> <p className="mt-2 text-xs leading-relaxed text-gray-500">
FFmpeg powers video thumbnails and metadata. It downloads once in the background you can keep FFmpeg powers video thumbnails and metadata. It downloads once in the background you can
using the tour and the app while it finishes. keep using the tour and the app while it finishes.
</p> </p>
</div> </div>
); )
} }
export function StepWelcome() { export function StepWelcome() {
const theme = useGalleryStore((s) => s.theme); const theme = useGalleryStore((s) => s.theme)
const setTheme = useGalleryStore((s) => s.setTheme); const setTheme = useGalleryStore((s) => s.setTheme)
return ( return (
<div> <div>
<p className="text-sm leading-relaxed text-gray-300"> <p className="text-sm leading-relaxed text-gray-300">
Phokus is a local-first media library: point it at your folders and it builds a fast, searchable Phokus is a local-first media library: point it at your folders and it builds a fast,
gallery with thumbnails, AI tags, and visual search. Everything is processed on this machine searchable gallery with thumbnails, AI tags, and visual search. Everything is processed on
your photos and videos never leave it. this machine your photos and videos never leave it.
</p> </p>
<p className="mt-2.5 text-sm leading-relaxed text-gray-500"> <p className="mt-2.5 text-sm leading-relaxed text-gray-500">
This short tour shows what to expect. Every step is skippable, and you can re-run it any time This short tour shows what to expect. Every step is skippable, and you can re-run it any
from Settings. time from Settings.
</p> </p>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Choose your look</h4> <h4 className="mt-7 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
Choose your look
</h4>
<div className="mt-3 grid grid-cols-3 gap-2"> <div className="mt-3 grid grid-cols-3 gap-2">
{THEME_OPTIONS.map((option) => { {THEME_OPTIONS.map((option) => {
const selected = option.value === theme; const selected = option.value === theme
return ( return (
<button <button
key={option.value} key={option.value}
type="button" type="button"
className={`rounded-md border p-2 text-left transition-colors ${ className={`rounded-md border p-2 text-left transition-colors ${
selected selected
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/40" ? 'border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/40'
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" : 'light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white'
}`} }`}
onClick={() => setTheme(option.value)} onClick={() => setTheme(option.value)}
> >
@@ -151,23 +173,40 @@ export function StepWelcome() {
className="block overflow-hidden rounded border border-black/20 p-1.5" className="block overflow-hidden rounded border border-black/20 p-1.5"
style={{ backgroundColor: option.colors.background }} style={{ backgroundColor: option.colors.background }}
> >
<span className="mb-1 block h-2 w-8 rounded-sm" style={{ backgroundColor: option.colors.accent }} /> <span
<span className="block rounded-sm p-1.5" style={{ backgroundColor: option.colors.surface }}> className="mb-1 block h-2 w-8 rounded-sm"
<span className="block h-1.5 w-9 rounded-sm" style={{ backgroundColor: option.colors.text }} /> style={{ backgroundColor: option.colors.accent }}
<span className="mt-1 block h-1.5 w-14 rounded-sm" style={{ backgroundColor: option.colors.muted }} /> />
<span className="mt-1 block h-1.5 w-10 rounded-sm" style={{ backgroundColor: option.colors.muted }} /> <span
className="block rounded-sm p-1.5"
style={{ backgroundColor: option.colors.surface }}
>
<span
className="block h-1.5 w-9 rounded-sm"
style={{ backgroundColor: option.colors.text }}
/>
<span
className="mt-1 block h-1.5 w-14 rounded-sm"
style={{ backgroundColor: option.colors.muted }}
/>
<span
className="mt-1 block h-1.5 w-10 rounded-sm"
style={{ backgroundColor: option.colors.muted }}
/>
</span> </span>
</span> </span>
<span className="mt-2 block text-[11px] font-medium">{option.name}</span> <span className="mt-2 block text-[11px] font-medium">{option.name}</span>
</button> </button>
); )
})} })}
</div> </div>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">First-time setup</h4> <h4 className="mt-7 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70"> First-time setup
</h4>
<div className="light-theme:divide-gray-300/70 mt-1 divide-y divide-white/[0.05]">
<FfmpegStatusRow /> <FfmpegStatusRow />
</div> </div>
</div> </div>
); )
} }
+96 -59
View File
@@ -2,21 +2,21 @@
// deliberately fake — rights-clean demo stills (generated, owned), looping // deliberately fake — rights-clean demo stills (generated, owned), looping
// demo progress — so new users see the real UI's shapes before their own // demo progress — so new users see the real UI's shapes before their own
// library exists. // library exists.
import sunset from "../../assets/onboarding/sunset.webp"; import sunset from '../../assets/onboarding/sunset.webp'
import sunsetcoast from "../../assets/onboarding/sunsetcoast.webp"; import sunsetcoast from '../../assets/onboarding/sunsetcoast.webp'
import sunsetlake from "../../assets/onboarding/sunsetlake.webp"; import sunsetlake from '../../assets/onboarding/sunsetlake.webp'
import beach from "../../assets/onboarding/beach.webp"; import beach from '../../assets/onboarding/beach.webp'
import landscape1 from "../../assets/onboarding/landscape1.webp"; import landscape1 from '../../assets/onboarding/landscape1.webp'
import landscape2 from "../../assets/onboarding/landscape2.webp"; import landscape2 from '../../assets/onboarding/landscape2.webp'
import forest from "../../assets/onboarding/forest.webp"; import forest from '../../assets/onboarding/forest.webp'
import citynight from "../../assets/onboarding/citynight.webp"; import citynight from '../../assets/onboarding/citynight.webp'
import flower from "../../assets/onboarding/flower.webp"; import flower from '../../assets/onboarding/flower.webp'
import alpinelake from "../../assets/onboarding/alpinelake.webp"; import alpinelake from '../../assets/onboarding/alpinelake.webp'
import dunes from "../../assets/onboarding/dunes.webp"; import dunes from '../../assets/onboarding/dunes.webp'
import cozy from "../../assets/onboarding/cozy.webp"; import cozy from '../../assets/onboarding/cozy.webp'
import cat from "../../assets/onboarding/cat.webp"; import cat from '../../assets/onboarding/cat.webp'
import balloon from "../../assets/onboarding/balloon.webp"; import balloon from '../../assets/onboarding/balloon.webp'
import architecture from "../../assets/onboarding/architecture.webp"; import architecture from '../../assets/onboarding/architecture.webp'
// Ordered for grid variety: adjacent indices are visually distinct. // Ordered for grid variety: adjacent indices are visually distinct.
const FAKE_IMAGES = [ const FAKE_IMAGES = [
@@ -33,10 +33,10 @@ const FAKE_IMAGES = [
landscape2, landscape2,
beach, beach,
architecture, architecture,
]; ]
export function fakeImage(index: number): string { export function fakeImage(index: number): string {
return FAKE_IMAGES[index % FAKE_IMAGES.length]; return FAKE_IMAGES[index % FAKE_IMAGES.length]
} }
// Result sets matched to what each query would genuinely return. // Result sets matched to what each query would genuinely return.
@@ -47,23 +47,23 @@ export const SEARCH_RESULTS = {
filename: [beach], filename: [beach],
semantic: [sunsetcoast, sunset, sunsetlake], semantic: [sunsetcoast, sunset, sunsetlake],
tags: [landscape1, landscape2, alpinelake], tags: [landscape1, landscape2, alpinelake],
} as const; } as const
// Abstract gradient blobs/ticks for the Explore & Timeline mini-previews, // Abstract gradient blobs/ticks for the Explore & Timeline mini-previews,
// where small non-photographic shapes read more clearly than tiny stills. // where small non-photographic shapes read more clearly than tiny stills.
const TILE_GRADIENTS = [ const TILE_GRADIENTS = [
"from-sky-900/70 via-slate-800 to-slate-900", 'from-sky-900/70 via-slate-800 to-slate-900',
"from-amber-900/60 via-stone-800 to-stone-900", 'from-amber-900/60 via-stone-800 to-stone-900',
"from-emerald-900/60 via-slate-800 to-gray-900", 'from-emerald-900/60 via-slate-800 to-gray-900',
"from-rose-900/50 via-slate-800 to-slate-900", 'from-rose-900/50 via-slate-800 to-slate-900',
"from-indigo-900/60 via-slate-800 to-gray-900", 'from-indigo-900/60 via-slate-800 to-gray-900',
"from-cyan-900/60 via-slate-800 to-slate-900", 'from-cyan-900/60 via-slate-800 to-slate-900',
"from-fuchsia-900/40 via-slate-800 to-gray-900", 'from-fuchsia-900/40 via-slate-800 to-gray-900',
"from-orange-900/50 via-stone-800 to-stone-900", 'from-orange-900/50 via-stone-800 to-stone-900',
]; ]
export function tileGradient(index: number): string { export function tileGradient(index: number): string {
return TILE_GRADIENTS[index % TILE_GRADIENTS.length]; return TILE_GRADIENTS[index % TILE_GRADIENTS.length]
} }
export function FakeTile({ export function FakeTile({
@@ -72,24 +72,31 @@ export function FakeTile({
favorite = false, favorite = false,
rating = 0, rating = 0,
duration, duration,
className = "", className = '',
}: { }: {
index: number; index: number
loaded?: boolean; loaded?: boolean
favorite?: boolean; favorite?: boolean
rating?: number; rating?: number
duration?: string; duration?: string
className?: string; className?: string
}) { }) {
return ( return (
<div className={`media-dark-surface group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}> <div
className={`media-dark-surface group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}
>
{loaded ? ( {loaded ? (
<img src={fakeImage(index)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" /> <img
src={fakeImage(index)}
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
) : ( ) : (
<div className="absolute inset-0 animate-pulse bg-white/[0.04]" /> <div className="absolute inset-0 animate-pulse bg-white/[0.04]" />
)} )}
{loaded && favorite ? ( {loaded && favorite ? (
<div className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm"> <div className="absolute top-1.5 right-1.5 rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" /> <path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg> </svg>
@@ -105,27 +112,41 @@ export function FakeTile({
</div> </div>
) : null} ) : null}
{loaded && duration ? ( {loaded && duration ? (
<div className="absolute bottom-1.5 right-1.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80"> <div className="absolute right-1.5 bottom-1.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80">
{duration} {duration}
</div> </div>
) : null} ) : null}
</div> </div>
); )
} }
export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) { export function FakeStageTag({
label,
state,
}: {
label: string
state: 'active' | 'done' | 'waiting'
}) {
const className = const className =
state === "active" state === 'active'
? "bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300" ? 'bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300'
: state === "done" : state === 'done'
? "bg-emerald-500/10 text-emerald-400 light-theme:bg-emerald-100 light-theme:text-emerald-700" ? 'bg-emerald-500/10 text-emerald-400 light-theme:bg-emerald-100 light-theme:text-emerald-700'
: "bg-gray-900/60 text-gray-600 light-theme:bg-gray-800 light-theme:text-gray-500"; : 'bg-gray-900/60 text-gray-600 light-theme:bg-gray-800 light-theme:text-gray-500'
return <span className={`rounded-md px-2 py-0.5 text-[11px] ${className}`}>{label}</span>; return <span className={`rounded-md px-2 py-0.5 text-[11px] ${className}`}>{label}</span>
} }
export function FakeProgressBar({ fraction, className = "" }: { fraction: number | null; className?: string }) { export function FakeProgressBar({
fraction,
className = '',
}: {
fraction: number | null
className?: string
}) {
return ( return (
<div className={`h-px overflow-hidden rounded-full bg-gray-200/60 light-theme:bg-gray-300 ${className}`}> <div
className={`light-theme:bg-gray-300 h-px overflow-hidden rounded-full bg-gray-200/60 ${className}`}
>
{fraction === null ? ( {fraction === null ? (
<div className="h-full w-full animate-pulse bg-blue-500/40" /> <div className="h-full w-full animate-pulse bg-blue-500/40" />
) : ( ) : (
@@ -135,27 +156,43 @@ export function FakeProgressBar({ fraction, className = "" }: { fraction: number
/> />
)} )}
</div> </div>
); )
} }
export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => void; label?: string }) { export function ReplayButton({
onClick,
label = 'Replay',
}: {
onClick: () => void
label?: string
}) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200"
> >
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12a7.5 7.5 0 0012.9 5.3M19.5 12A7.5 7.5 0 006.6 6.7M4.5 6.5v3h3M19.5 17.5v-3h-3" /> className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12a7.5 7.5 0 0012.9 5.3M19.5 12A7.5 7.5 0 006.6 6.7M4.5 6.5v3h3M19.5 17.5v-3h-3"
/>
</svg> </svg>
{label} {label}
</button> </button>
); )
} }
export function formatBytes(bytes: number): string { export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${bytes} B`; return `${bytes} B`
} }
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from 'react'
import { useGalleryStore } from "../../store"; import { useGalleryStore } from '../../store'
import { TAGGER_MODELS } from "../../taggerModels"; import { TAGGER_MODELS } from '../../taggerModels'
import { import {
formatBytesShort, formatBytesShort,
ScopeButton, ScopeButton,
@@ -10,207 +10,212 @@ import {
StatusPill, StatusPill,
TaggerAccelerationButton, TaggerAccelerationButton,
TaggerModelButton, TaggerModelButton,
} from "./shared"; } from './shared'
export function AiWorkspaceSettingsSection() { export function AiWorkspaceSettingsSection() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders)
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress)
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope); const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope)
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds); const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds)
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope); const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope)
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder); const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder)
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds); const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds)
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus)
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing); const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing)
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress); const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress)
const taggerModelError = useGalleryStore((state) => state.taggerModelError); const taggerModelError = useGalleryStore((state) => state.taggerModelError)
const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration); const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration)
const taggerThreshold = useGalleryStore((state) => state.taggerThreshold); const taggerThreshold = useGalleryStore((state) => state.taggerThreshold)
const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize); const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize)
const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe); const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe)
const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking); const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking)
const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel); const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel)
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel); const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel)
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration); const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration)
const taggerModel = useGalleryStore((state) => state.taggerModel); const taggerModel = useGalleryStore((state) => state.taggerModel)
const setTaggerModel = useGalleryStore((state) => state.setTaggerModel); const setTaggerModel = useGalleryStore((state) => state.setTaggerModel)
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold); const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold)
const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize); const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize)
const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime); const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime)
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs)
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders); const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders)
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs)
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders)
const resetAiTags = useGalleryStore((state) => state.resetAiTags); const resetAiTags = useGalleryStore((state) => state.resetAiTags)
const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders); const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders)
const openTagManager = useGalleryStore((state) => state.openTagManager); const openTagManager = useGalleryStore((state) => state.openTagManager)
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null); const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null)
const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerQueueing, setTaggerQueueing] = useState(false)
const [taggerClearing, setTaggerClearing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false)
const [taggerResetConfirming, setTaggerResetConfirming] = useState(false); const [taggerResetConfirming, setTaggerResetConfirming] = useState(false)
const [taggerResetting, setTaggerResetting] = useState(false); const [taggerResetting, setTaggerResetting] = useState(false)
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false)
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null); const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null)
const [taggerModelSwitching, setTaggerModelSwitching] = useState(false); const [taggerModelSwitching, setTaggerModelSwitching] = useState(false)
const [taggerModelSwitchError, setTaggerModelSwitchError] = useState<string | null>(null); const [taggerModelSwitchError, setTaggerModelSwitchError] = useState<string | null>(null)
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null); const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null)
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false); const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false)
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null); const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null)
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null); const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null)
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false); const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false)
const [taggerBatchSizeError, setTaggerBatchSizeError] = useState<string | null>(null); const [taggerBatchSizeError, setTaggerBatchSizeError] = useState<string | null>(null)
const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
return () => { return () => {
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current); if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current)
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current); if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current)
}; }
}, []); }, [])
const selectedFolders = useMemo( const selectedFolders = useMemo(
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)), () => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
[folders, taggingQueueFolderIds], [folders, taggingQueueFolderIds]
); )
const taggerReady = taggerModelStatus?.ready ?? false; const taggerReady = taggerModelStatus?.ready ?? false
const queueScopeLabel = const queueScopeLabel =
taggingQueueScope === "all" taggingQueueScope === 'all'
? "all media" ? 'all media'
: selectedFolders.length > 0 : selectedFolders.length > 0
? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? "" : "s"}` ? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? '' : 's'}`
: "no folders selected"; : 'no folders selected'
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold); const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold)
const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize); const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize)
const taggerBytesKnown = const taggerBytesKnown =
taggerModelProgress?.downloaded_bytes != null && taggerModelProgress?.downloaded_bytes != null &&
taggerModelProgress.total_bytes != null && taggerModelProgress.total_bytes != null &&
taggerModelProgress.total_bytes > 0; taggerModelProgress.total_bytes > 0
const taggerDownloadLabel = taggerBytesKnown const taggerDownloadLabel = taggerBytesKnown
? `Downloading ${formatBytesShort(taggerModelProgress!.downloaded_bytes!)} / ${formatBytesShort(taggerModelProgress!.total_bytes!)}` ? `Downloading ${formatBytesShort(taggerModelProgress!.downloaded_bytes!)} / ${formatBytesShort(taggerModelProgress!.total_bytes!)}`
: taggerModelProgress : taggerModelProgress
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}` ? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
: taggerModelPreparing : taggerModelPreparing
? "Preparing AI tagger..." ? 'Preparing AI tagger...'
: taggerReady : taggerReady
? "Installed" ? 'Installed'
: "Install model"; : 'Install model'
const taggerDownloadPercent = taggerBytesKnown const taggerDownloadPercent = taggerBytesKnown
? Math.round((taggerModelProgress!.downloaded_bytes! / taggerModelProgress!.total_bytes!) * 100) ? Math.round((taggerModelProgress!.downloaded_bytes! / taggerModelProgress!.total_bytes!) * 100)
: taggerModelProgress : taggerModelProgress
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100) ? Math.round(
: 0; (taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100
)
: 0
const runQueueAction = (action: "queue" | "clear") => { const runQueueAction = (action: 'queue' | 'clear') => {
const selectedIds = taggingQueueFolderIds; const selectedIds = taggingQueueFolderIds
const perform = const perform =
taggingQueueScope === "all" taggingQueueScope === 'all'
? action === "queue" ? action === 'queue'
? queueTaggingJobs(null) ? queueTaggingJobs(null)
: clearTaggingJobs(null) : clearTaggingJobs(null)
: selectedIds.length > 0 : selectedIds.length > 0
? action === "queue" ? action === 'queue'
? queueTaggingJobsForFolders(selectedIds) ? queueTaggingJobsForFolders(selectedIds)
: clearTaggingJobsForFolders(selectedIds) : clearTaggingJobsForFolders(selectedIds)
: Promise.resolve(0); : Promise.resolve(0)
if (action === "queue") { if (action === 'queue') {
setTaggerQueueing(true); setTaggerQueueing(true)
} else { } else {
setTaggerClearing(true); setTaggerClearing(true)
} }
setTaggerQueueStatus(null); setTaggerQueueStatus(null)
setTaggerResetConfirming(false); setTaggerResetConfirming(false)
void perform void perform
.then((count) => { .then((count) => {
if (taggingQueueScope === "selected" && selectedIds.length === 0) { if (taggingQueueScope === 'selected' && selectedIds.length === 0) {
setTaggerQueueStatus("Choose at least one folder before running tagging jobs."); setTaggerQueueStatus('Choose at least one folder before running tagging jobs.')
return; return
} }
setTaggerQueueStatus( setTaggerQueueStatus(
count === 0 count === 0
? action === "queue" ? action === 'queue'
? "No missing tags found for the current target." ? 'No missing tags found for the current target.'
: "No queued tagging jobs to clear for the current target." : 'No queued tagging jobs to clear for the current target.'
: action === "queue" : action === 'queue'
? `Queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for tagging.` ? `Queued ${count.toLocaleString()} image${count === 1 ? '' : 's'} for tagging.`
: `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? "" : "s"}.`, : `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? '' : 's'}.`
); )
}) })
.catch((error) => setTaggerQueueStatus(String(error))) .catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => { .finally(() => {
if (action === "queue") { if (action === 'queue') {
setTaggerQueueing(false); setTaggerQueueing(false)
} else { } else {
setTaggerClearing(false); setTaggerClearing(false)
} }
}); })
}; }
const runResetAiTags = () => { const runResetAiTags = () => {
if (!taggerResetConfirming) { if (!taggerResetConfirming) {
setTaggerResetConfirming(true); setTaggerResetConfirming(true)
setTaggerQueueStatus( setTaggerQueueStatus(
`Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`, `Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`
); )
return; return
} }
const selectedIds = taggingQueueFolderIds; const selectedIds = taggingQueueFolderIds
const perform = const perform =
taggingQueueScope === "all" taggingQueueScope === 'all'
? resetAiTags(null) ? resetAiTags(null)
: selectedIds.length > 0 : selectedIds.length > 0
? resetAiTagsForFolders(selectedIds) ? resetAiTagsForFolders(selectedIds)
: Promise.resolve(0); : Promise.resolve(0)
setTaggerResetting(true); setTaggerResetting(true)
setTaggerQueueStatus(null); setTaggerQueueStatus(null)
void perform void perform
.then((count) => { .then((count) => {
if (taggingQueueScope === "selected" && selectedIds.length === 0) { if (taggingQueueScope === 'selected' && selectedIds.length === 0) {
setTaggerQueueStatus("Choose at least one folder before resetting AI tags."); setTaggerQueueStatus('Choose at least one folder before resetting AI tags.')
return; return
} }
setTaggerQueueStatus( setTaggerQueueStatus(
count === 0 count === 0
? "No AI tag data found for the current target." ? 'No AI tag data found for the current target.'
: `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`, : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? '' : 's'}. Queue tagging when you're ready to retag.`
); )
}) })
.catch((error) => setTaggerQueueStatus(String(error))) .catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => { .finally(() => {
setTaggerResetting(false); setTaggerResetting(false)
setTaggerResetConfirming(false); setTaggerResetConfirming(false)
}); })
}; }
return ( return (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
<SettingsGroup title="Model"> <SettingsGroup title="Model">
<SettingsItem label="Tagging model" description="Choose which model generates tags. JoyTag suits photos and NSFW; WD is anime-focused. Switching may require a one-time download."> <SettingsItem
label="Tagging model"
description="Choose which model generates tags. JoyTag suits photos and NSFW; WD is anime-focused. Switching may require a one-time download."
>
<div className="flex flex-col items-end gap-1.5"> <div className="flex flex-col items-end gap-1.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80"> <div className="light-theme:border-gray-300/80 flex rounded-lg border border-white/[0.07] p-0.5">
{(["wd", "joytag"] as const).map((model) => ( {(['wd', 'joytag'] as const).map((model) => (
<TaggerModelButton <TaggerModelButton
key={model} key={model}
model={model} model={model}
current={taggerModel} current={taggerModel}
onSelect={(nextModel) => { onSelect={(nextModel) => {
if (nextModel === taggerModel) return; if (nextModel === taggerModel) return
setTaggerThresholdDraft(null); setTaggerThresholdDraft(null)
setTaggerThresholdError(null); setTaggerThresholdError(null)
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current); if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current)
setTaggerModelSwitching(true); setTaggerModelSwitching(true)
setTaggerModelSwitchError(null); setTaggerModelSwitchError(null)
void setTaggerModel(nextModel) void setTaggerModel(nextModel)
.catch((error: unknown) => setTaggerModelSwitchError(String(error))) .catch((error: unknown) => setTaggerModelSwitchError(String(error)))
.finally(() => setTaggerModelSwitching(false)); .finally(() => setTaggerModelSwitching(false))
}} }}
> >
{TAGGER_MODELS[model].tab} {TAGGER_MODELS[model].tab}
@@ -220,7 +225,11 @@ export function AiWorkspaceSettingsSection() {
{taggerModelSwitchError ? ( {taggerModelSwitchError ? (
<p className="text-[11px] text-amber-300">{taggerModelSwitchError}</p> <p className="text-[11px] text-amber-300">{taggerModelSwitchError}</p>
) : ( ) : (
<p className="text-[11px] text-gray-600">{taggerModelSwitching ? "Switching..." : `Current: ${TAGGER_MODELS[taggerModel].name}`}</p> <p className="text-[11px] text-gray-600">
{taggerModelSwitching
? 'Switching...'
: `Current: ${TAGGER_MODELS[taggerModel].name}`}
</p>
)} )}
</div> </div>
</SettingsItem> </SettingsItem>
@@ -228,10 +237,10 @@ export function AiWorkspaceSettingsSection() {
<SettingsItem <SettingsItem
label={ label={
<> <>
{TAGGER_MODELS[taggerModel].name}{" "} {TAGGER_MODELS[taggerModel].name}{' '}
<span className="ml-1.5 align-middle"> <span className="ml-1.5 align-middle">
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}> <StatusPill tone={taggerReady ? 'ready' : taggerModelPreparing ? 'busy' : 'muted'}>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"} {taggerReady ? 'Installed' : taggerModelPreparing ? 'Preparing' : 'Not installed'}
</StatusPill> </StatusPill>
</span> </span>
</> </>
@@ -242,11 +251,15 @@ export function AiWorkspaceSettingsSection() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{taggerReady ? ( {taggerReady ? (
<> <>
<button className={settingsButtonClass} onClick={() => void probeTaggerRuntime()} disabled={taggerRuntimeChecking}> <button
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"} className={settingsButtonClass}
onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? 'Checking runtime...' : 'Check runtime'}
</button> </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 light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100" className="light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100 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"
onClick={() => void deleteTaggerModel()} onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing} disabled={taggerModelPreparing}
> >
@@ -254,8 +267,17 @@ export function AiWorkspaceSettingsSection() {
</button> </button>
</> </>
) : ( ) : (
<button className={`relative overflow-hidden ${settingsButtonClass}`} onClick={() => void prepareTaggerModel()} disabled={taggerModelPreparing}> <button
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null} className={`relative overflow-hidden ${settingsButtonClass}`}
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> <span className="relative">{taggerDownloadLabel}</span>
</button> </button>
)} )}
@@ -263,44 +285,63 @@ export function AiWorkspaceSettingsSection() {
{taggerRuntimeProbe ? ( {taggerRuntimeProbe ? (
<div className="text-right"> <div className="text-right">
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span> Runtime check{' '}
{" "}<span className="ml-2 text-gray-600">· acceleration: {taggerRuntimeProbe.acceleration}</span> <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 font-mono text-xs break-all text-gray-600">
{taggerRuntimeProbe.session.file}
</p> </p>
<p className="mt-1 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
</div> </div>
) : null} ) : null}
</div> </div>
</SettingsItem> </SettingsItem>
<SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability."> <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 flex-col items-end gap-1.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80"> <div className="light-theme:border-gray-300/80 flex rounded-lg border border-white/[0.07] p-0.5">
{(["auto", "directml", "cpu"] as const).map((acceleration) => ( {(['auto', 'directml', 'cpu'] as const).map((acceleration) => (
<TaggerAccelerationButton <TaggerAccelerationButton
key={acceleration} key={acceleration}
acceleration={acceleration} acceleration={acceleration}
current={taggerAcceleration} current={taggerAcceleration}
onSelect={(nextAcceleration) => { onSelect={(nextAcceleration) => {
setTaggerAccelerationSaving(true); setTaggerAccelerationSaving(true)
setTaggerAccelerationError(null); setTaggerAccelerationError(null)
void setTaggerAcceleration(nextAcceleration) void setTaggerAcceleration(nextAcceleration)
.catch((error: unknown) => setTaggerAccelerationError(String(error))) .catch((error: unknown) => setTaggerAccelerationError(String(error)))
.finally(() => setTaggerAccelerationSaving(false)); .finally(() => setTaggerAccelerationSaving(false))
}} }}
> >
{acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"} {acceleration === 'directml'
? 'DirectML'
: acceleration === 'cpu'
? 'CPU'
: 'Auto'}
</TaggerAccelerationButton> </TaggerAccelerationButton>
))} ))}
</div> </div>
{taggerAccelerationError ? ( {taggerAccelerationError ? (
<p className="text-[11px] text-amber-300">{taggerAccelerationError}</p> <p className="text-[11px] text-amber-300">{taggerAccelerationError}</p>
) : ( ) : (
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p> <p className="text-[11px] text-gray-600">
{taggerAccelerationSaving ? 'Saving...' : `Current: ${taggerAcceleration}`}
</p>
)} )}
</div> </div>
</SettingsItem> </SettingsItem>
<SettingsItem label="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner."> <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"> <div className="flex flex-col items-end gap-1.5">
<input <input
type="number" type="number"
@@ -311,33 +352,43 @@ export function AiWorkspaceSettingsSection() {
value={thresholdDisplay} value={thresholdDisplay}
onChange={(event) => setTaggerThresholdDraft(event.target.value)} onChange={(event) => setTaggerThresholdDraft(event.target.value)}
onBlur={(event) => { onBlur={(event) => {
const value = parseFloat(event.currentTarget.value); const value = parseFloat(event.currentTarget.value)
if (!isNaN(value) && value >= 0.05 && value <= 0.99) { if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdError(null); setTaggerThresholdError(null)
setTaggerThresholdSaving(true); setTaggerThresholdSaving(true)
void setTaggerThreshold(value, taggerModel) void setTaggerThreshold(value, taggerModel)
.catch((error: unknown) => setTaggerThresholdError(String(error))) .catch((error: unknown) => setTaggerThresholdError(String(error)))
.finally(() => { .finally(() => {
setTaggerThresholdDraft(null); setTaggerThresholdDraft(null)
setTaggerThresholdSaving(false); setTaggerThresholdSaving(false)
}); })
} else { } else {
setTaggerThresholdDraft(null); setTaggerThresholdDraft(null)
setTaggerThresholdError("Must be 0.05 0.99"); setTaggerThresholdError('Must be 0.05 0.99')
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current); if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current)
thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000); thresholdErrorTimerRef.current = setTimeout(
() => setTaggerThresholdError(null),
2000
)
} }
}} }}
/> />
{taggerThresholdError ? ( {taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p> <p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : ( ) : (
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : `Default: ${TAGGER_MODELS[taggerModel].defaultThreshold}`}</p> <p className="text-[11px] text-gray-600">
{taggerThresholdSaving
? 'Saving...'
: `Default: ${TAGGER_MODELS[taggerModel].defaultThreshold}`}
</p>
)} )}
</div> </div>
</SettingsItem> </SettingsItem>
<SettingsItem label="Tagging batch size" description="Number of images processed concurrently during tag generation."> <SettingsItem
label="Tagging batch size"
description="Number of images processed concurrently during tag generation."
>
<div className="flex flex-col items-end gap-1.5"> <div className="flex flex-col items-end gap-1.5">
<input <input
type="number" type="number"
@@ -348,48 +399,73 @@ export function AiWorkspaceSettingsSection() {
value={batchSizeDisplay} value={batchSizeDisplay}
onChange={(event) => setTaggerBatchSizeDraft(event.target.value)} onChange={(event) => setTaggerBatchSizeDraft(event.target.value)}
onBlur={() => { onBlur={() => {
const value = parseInt(batchSizeDisplay, 10); const value = parseInt(batchSizeDisplay, 10)
if (!isNaN(value) && value >= 1 && value <= 100) { if (!isNaN(value) && value >= 1 && value <= 100) {
setTaggerBatchSizeError(null); setTaggerBatchSizeError(null)
setTaggerBatchSizeSaving(true); setTaggerBatchSizeSaving(true)
void setTaggerBatchSize(value) void setTaggerBatchSize(value)
.catch((error: unknown) => setTaggerQueueStatus(String(error))) .catch((error: unknown) => setTaggerQueueStatus(String(error)))
.finally(() => { .finally(() => {
setTaggerBatchSizeDraft(null); setTaggerBatchSizeDraft(null)
setTaggerBatchSizeSaving(false); setTaggerBatchSizeSaving(false)
}); })
} else { } else {
setTaggerBatchSizeDraft(null); setTaggerBatchSizeDraft(null)
setTaggerBatchSizeError("Must be 1 100"); setTaggerBatchSizeError('Must be 1 100')
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current); if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current)
batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000); batchSizeErrorTimerRef.current = setTimeout(
() => setTaggerBatchSizeError(null),
2000
)
} }
}} }}
/> />
{taggerBatchSizeError ? ( {taggerBatchSizeError ? (
<p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p> <p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p>
) : ( ) : (
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p> <p className="text-[11px] text-gray-600">
{taggerBatchSizeSaving ? 'Saving...' : 'Default: 8'}
</p>
)} )}
</div> </div>
</SettingsItem> </SettingsItem>
<SettingsItem label="Model location" vertical> <SettingsItem label="Model location" vertical>
<div> <div>
<p className="break-all font-mono text-xs text-gray-600"> <p className="font-mono text-xs break-all text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"} {taggerReady ? taggerModelStatus?.local_dir : 'Not downloaded'}
</p> </p>
{taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null} {taggerModelProgress?.current_file ? (
{taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null} <p className="mt-2 text-xs break-all text-gray-500">
{taggerModelProgress.current_file}
</p>
) : null}
{taggerModelError ? (
<p className="mt-2 text-xs text-amber-300">{taggerModelError}</p>
) : null}
</div> </div>
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Queue targets" description="Choose which folders to include when queuing tagging jobs."> <SettingsGroup
<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."> title="Queue targets"
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80"> description="Choose which folders to include when queuing tagging jobs."
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton> >
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton> <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="light-theme:border-gray-300/80 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> </div>
</SettingsItem> </SettingsItem>
@@ -397,86 +473,123 @@ export function AiWorkspaceSettingsSection() {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<p className="text-sm 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> <p className="mt-1 text-xs leading-relaxed text-gray-500">
Current target: {queueScopeLabel}.
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white 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:cursor-not-allowed disabled:opacity-40"
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))} onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={taggingQueueScope === "all" || folders.length === 0} disabled={taggingQueueScope === 'all' || folders.length === 0}
> >
Select all Select all
</button> </button>
<button <button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white 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:cursor-not-allowed disabled:opacity-40"
onClick={() => setTaggingQueueFolderIds([])} onClick={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0} disabled={taggingQueueScope === 'all' || taggingQueueFolderIds.length === 0}
> >
Clear Clear
</button> </button>
</div> </div>
</div> </div>
<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={`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) => { {folders.map((folder) => {
const active = taggingQueueFolderIds.includes(folder.id); const active = taggingQueueFolderIds.includes(folder.id)
const progress = mediaJobProgress[folder.id]; const progress = mediaJobProgress[folder.id]
return ( return (
<button <button
key={folder.id} key={folder.id}
type="button" type="button"
className={`flex w-full items-center justify-between gap-3 px-1 py-2 text-left transition-colors disabled:cursor-not-allowed ${ 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" active ? 'text-white' : 'text-gray-400 hover:text-gray-200'
}`} }`}
onClick={() => toggleTaggingQueueFolder(folder.id)} onClick={() => toggleTaggingQueueFolder(folder.id)}
disabled={taggingQueueScope === "all"} disabled={taggingQueueScope === 'all'}
> >
<p className="min-w-0 truncate text-sm">{folder.name}</p> <p className="min-w-0 truncate text-sm">{folder.name}</p>
<div className="flex shrink-0 items-center gap-3"> <div className="flex shrink-0 items-center gap-3">
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null} {(progress?.tagging_pending ?? 0) > 0 ? (
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()} items</span> <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill>
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} /> ) : null}
<span className="text-[11px] text-gray-600 tabular-nums">
{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> </div>
</button> </button>
); )
})} })}
{folders.length === 0 ? <p className="py-2 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>
</div> </div>
<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."> <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"> <div className="flex items-center gap-2">
<button <button
className={settingsButtonClass} className={settingsButtonClass}
onClick={() => runQueueAction("queue")} onClick={() => runQueueAction('queue')}
disabled={!taggerReady || taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} disabled={
!taggerReady ||
taggerQueueing ||
taggerClearing ||
taggerResetting ||
(taggingQueueScope === 'selected' && taggingQueueFolderIds.length === 0)
}
> >
{taggerQueueing ? "Queueing..." : "Queue tagging"} {taggerQueueing ? 'Queueing...' : 'Queue tagging'}
</button> </button>
<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 light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100" className="light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100 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={() => runQueueAction('clear')}
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} disabled={
taggerQueueing ||
taggerClearing ||
taggerResetting ||
(taggingQueueScope === 'selected' && taggingQueueFolderIds.length === 0)
}
> >
{taggerClearing ? "Clearing..." : "Clear queued jobs"} {taggerClearing ? 'Clearing...' : 'Clear queued jobs'}
</button> </button>
<button <button
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${ className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
taggerResetConfirming taggerResetConfirming
? "border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25 light-theme:border-red-500/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100" ? 'light-theme:border-red-500/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100 border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25'
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" : 'light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white'
}`} }`}
onClick={runResetAiTags} onClick={runResetAiTags}
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} disabled={
taggerQueueing ||
taggerClearing ||
taggerResetting ||
(taggingQueueScope === 'selected' && taggingQueueFolderIds.length === 0)
}
> >
{taggerResetting ? "Resetting..." : taggerResetConfirming ? "Confirm reset" : "Reset AI tags"} {taggerResetting
? 'Resetting...'
: taggerResetConfirming
? 'Confirm reset'
: 'Reset AI tags'}
</button> </button>
{taggerResetConfirming ? ( {taggerResetConfirming ? (
<button <button
className="rounded-md border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:border-gray-700/50 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white" className="light-theme:border-gray-700/50 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white rounded-md border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={() => { onClick={() => {
setTaggerResetConfirming(false); setTaggerResetConfirming(false)
setTaggerQueueStatus(null); setTaggerQueueStatus(null)
}} }}
disabled={taggerResetting} disabled={taggerResetting}
> >
@@ -486,16 +599,24 @@ export function AiWorkspaceSettingsSection() {
</div> </div>
</SettingsItem> </SettingsItem>
{taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null} {taggerQueueStatus ? (
<p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p>
) : null}
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Tag library" description="Review and clean up the tags across your library."> <SettingsGroup
<SettingsItem label="Manage tags" description="Open the tag manager in Explore to search, rename, and delete tags."> title="Tag library"
description="Review and clean up the tags across your library."
>
<SettingsItem
label="Manage tags"
description="Open the tag manager in Explore to search, rename, and delete tags."
>
<button className={settingsButtonClass} onClick={openTagManager}> <button className={settingsButtonClass} onClick={openTagManager}>
Open tag manager Open tag manager
</button> </button>
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
</div> </div>
); )
} }
@@ -1,27 +1,30 @@
import { Dropdown } from "../menu"; import { Dropdown } from '../menu'
import { useGalleryStore } from "../../store"; import { useGalleryStore } from '../../store'
import { SettingsGroup, SettingsItem } from "./shared"; import { SettingsGroup, SettingsItem } from './shared'
export function GeneralSettingsSection() { export function GeneralSettingsSection() {
const theme = useGalleryStore((state) => state.theme); const theme = useGalleryStore((state) => state.theme)
const setTheme = useGalleryStore((state) => state.setTheme); const setTheme = useGalleryStore((state) => state.setTheme)
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused); const notificationsPaused = useGalleryStore((state) => state.notificationsPaused)
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused); const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused)
const workerPausesPersist = useGalleryStore((state) => state.workerPausesPersist); const workerPausesPersist = useGalleryStore((state) => state.workerPausesPersist)
const setWorkerPausesPersist = useGalleryStore((state) => state.setWorkerPausesPersist); const setWorkerPausesPersist = useGalleryStore((state) => state.setWorkerPausesPersist)
return ( return (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
<SettingsGroup title="Appearance"> <SettingsGroup title="Appearance">
<SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background."> <SettingsItem
label="Theme"
description="Choose the app palette. Subtle Light uses a warm, low-glare background."
>
<Dropdown <Dropdown
value={theme} value={theme}
onChange={setTheme} onChange={setTheme}
ariaLabel="App theme" ariaLabel="App theme"
options={[ options={[
{ value: "phokus", label: "Phokus" }, { value: 'phokus', label: 'Phokus' },
{ value: "subtle-light", label: "Subtle Light" }, { value: 'subtle-light', label: 'Subtle Light' },
{ value: "conventional-dark", label: "Conventional Dark" }, { value: 'conventional-dark', label: 'Conventional Dark' },
]} ]}
/> />
</SettingsItem> </SettingsItem>
@@ -35,10 +38,12 @@ export function GeneralSettingsSection() {
<button <button
role="switch" role="switch"
aria-checked={notificationsPaused} aria-checked={notificationsPaused}
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"}`} 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'}`}
onClick={() => setNotificationsPaused(!notificationsPaused)} onClick={() => setNotificationsPaused(!notificationsPaused)}
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} /> <span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? 'translate-x-4' : 'translate-x-0'}`}
/>
</button> </button>
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
@@ -48,13 +53,15 @@ export function GeneralSettingsSection() {
<button <button
role="switch" role="switch"
aria-checked={workerPausesPersist} aria-checked={workerPausesPersist}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${workerPausesPersist ? "bg-sky-500" : "bg-white/15"}`} className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${workerPausesPersist ? 'bg-sky-500' : 'bg-white/15'}`}
onClick={() => setWorkerPausesPersist(!workerPausesPersist)} onClick={() => setWorkerPausesPersist(!workerPausesPersist)}
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${workerPausesPersist ? "translate-x-4" : "translate-x-0"}`} /> <span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${workerPausesPersist ? 'translate-x-4' : 'translate-x-0'}`}
/>
</button> </button>
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
</div> </div>
); )
} }
@@ -1,18 +1,18 @@
import { Dropdown } from "../menu"; import { Dropdown } from '../menu'
import { useGalleryStore } from "../../store"; import { useGalleryStore } from '../../store'
import { SettingsGroup, SettingsItem } from "./shared"; import { SettingsGroup, SettingsItem } from './shared'
export function MediaSettingsSection() { export function MediaSettingsSection() {
const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay); const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay)
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay); const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay)
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute); const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute)
const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute); const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute)
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds); const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds)
const setSlideshowIntervalSeconds = useGalleryStore((state) => state.setSlideshowIntervalSeconds); const setSlideshowIntervalSeconds = useGalleryStore((state) => state.setSlideshowIntervalSeconds)
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder); const slideshowOrder = useGalleryStore((state) => state.slideshowOrder)
const setSlideshowOrder = useGalleryStore((state) => state.setSlideshowOrder); const setSlideshowOrder = useGalleryStore((state) => state.setSlideshowOrder)
const slideshowTransition = useGalleryStore((state) => state.slideshowTransition); const slideshowTransition = useGalleryStore((state) => state.slideshowTransition)
const setSlideshowTransition = useGalleryStore((state) => state.setSlideshowTransition); const setSlideshowTransition = useGalleryStore((state) => state.setSlideshowTransition)
return ( return (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
@@ -24,10 +24,12 @@ export function MediaSettingsSection() {
<button <button
role="switch" role="switch"
aria-checked={lightboxAutoplay} aria-checked={lightboxAutoplay}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoplay ? "bg-sky-500" : "bg-white/15"}`} className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoplay ? 'bg-sky-500' : 'bg-white/15'}`}
onClick={() => setLightboxAutoplay(!lightboxAutoplay)} onClick={() => setLightboxAutoplay(!lightboxAutoplay)}
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoplay ? "translate-x-4" : "translate-x-0"}`} /> <span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoplay ? 'translate-x-4' : 'translate-x-0'}`}
/>
</button> </button>
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
@@ -37,10 +39,12 @@ export function MediaSettingsSection() {
<button <button
role="switch" role="switch"
aria-checked={lightboxAutoMute} aria-checked={lightboxAutoMute}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoMute ? "bg-sky-500" : "bg-white/15"}`} className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoMute ? 'bg-sky-500' : 'bg-white/15'}`}
onClick={() => setLightboxAutoMute(!lightboxAutoMute)} onClick={() => setLightboxAutoMute(!lightboxAutoMute)}
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoMute ? "translate-x-4" : "translate-x-0"}`} /> <span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoMute ? 'translate-x-4' : 'translate-x-0'}`}
/>
</button> </button>
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
@@ -61,7 +65,9 @@ export function MediaSettingsSection() {
className="w-32 accent-sky-500" className="w-32 accent-sky-500"
onChange={(event) => setSlideshowIntervalSeconds(Number(event.currentTarget.value))} onChange={(event) => setSlideshowIntervalSeconds(Number(event.currentTarget.value))}
/> />
<span className="min-w-10 text-right text-xs tabular-nums text-gray-400">{slideshowIntervalSeconds}s</span> <span className="min-w-10 text-right text-xs text-gray-400 tabular-nums">
{slideshowIntervalSeconds}s
</span>
</div> </div>
</SettingsItem> </SettingsItem>
<SettingsItem <SettingsItem
@@ -73,8 +79,8 @@ export function MediaSettingsSection() {
onChange={setSlideshowOrder} onChange={setSlideshowOrder}
ariaLabel="Slideshow order" ariaLabel="Slideshow order"
options={[ options={[
{ value: "sequential", label: "Sequential" }, { value: 'sequential', label: 'Sequential' },
{ value: "random", label: "Random" }, { value: 'random', label: 'Random' },
]} ]}
/> />
</SettingsItem> </SettingsItem>
@@ -87,12 +93,12 @@ export function MediaSettingsSection() {
onChange={setSlideshowTransition} onChange={setSlideshowTransition}
ariaLabel="Slideshow transition" ariaLabel="Slideshow transition"
options={[ options={[
{ value: "soft-fade", label: "Soft fade" }, { value: 'soft-fade', label: 'Soft fade' },
{ value: "gentle-motion", label: "Gentle motion" }, { value: 'gentle-motion', label: 'Gentle motion' },
]} ]}
/> />
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
</div> </div>
); )
} }
@@ -1,31 +1,42 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, VacuumResult, useGalleryStore } from "../../store"; import {
import { SettingsGroup, SettingsItem, settingsButtonClass, StatPair } from "./shared"; CleanupOrphanedThumbnailsResult,
DatabaseInfo,
OrphanedThumbnailsInfo,
VacuumResult,
useGalleryStore,
} from '../../store'
import { SettingsGroup, SettingsItem, settingsButtonClass, StatPair } from './shared'
export function StorageSettingsSection() { export function StorageSettingsSection() {
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder)
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo)
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase)
const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex); const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex)
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo); const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo)
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails); const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails)
const [openingDataFolder, setOpeningDataFolder] = useState(false); const [openingDataFolder, setOpeningDataFolder] = useState(false)
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null); const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null)
const [vacuuming, setVacuuming] = useState(false); const [vacuuming, setVacuuming] = useState(false)
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null); const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null)
const [rebuildingIndex, setRebuildingIndex] = useState(false); const [rebuildingIndex, setRebuildingIndex] = useState(false)
const [rebuildIndexResult, setRebuildIndexResult] = useState<string | null>(null); const [rebuildIndexResult, setRebuildIndexResult] = useState<string | null>(null)
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null); const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null)
const [cleaningThumbnails, setCleaningThumbnails] = useState(false); const [cleaningThumbnails, setCleaningThumbnails] = useState(false)
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null); const [thumbnailCleanupResult, setThumbnailCleanupResult] =
useState<CleanupOrphanedThumbnailsResult | null>(null)
useEffect(() => { useEffect(() => {
setVacuumResult(null); setVacuumResult(null)
setThumbnailCleanupResult(null); setThumbnailCleanupResult(null)
void getDatabaseInfo().then(setDbInfo).catch(() => {}); void getDatabaseInfo()
void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {}); .then(setDbInfo)
}, [getDatabaseInfo, getOrphanedThumbnailsInfo]); .catch(() => {})
void getOrphanedThumbnailsInfo()
.then(setThumbnailInfo)
.catch(() => {})
}, [getDatabaseInfo, getOrphanedThumbnailsInfo])
return ( return (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
@@ -37,12 +48,12 @@ export function StorageSettingsSection() {
<button <button
className={settingsButtonClass} className={settingsButtonClass}
onClick={() => { onClick={() => {
setOpeningDataFolder(true); setOpeningDataFolder(true)
void openAppDataFolder().finally(() => setOpeningDataFolder(false)); void openAppDataFolder().finally(() => setOpeningDataFolder(false))
}} }}
disabled={openingDataFolder} disabled={openingDataFolder}
> >
{openingDataFolder ? "Opening..." : "Open data folder"} {openingDataFolder ? 'Opening...' : 'Open data folder'}
</button> </button>
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
@@ -52,7 +63,10 @@ export function StorageSettingsSection() {
label="Compact database" label="Compact database"
description={ description={
<> <>
<span>Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time.</span> <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"> <span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<StatPair <StatPair
label="Size" label="Size"
@@ -61,7 +75,7 @@ export function StorageSettingsSection() {
? `${vacuumResult.after_mb.toFixed(1)} MB` ? `${vacuumResult.after_mb.toFixed(1)} MB`
: dbInfo : dbInfo
? `${dbInfo.size_mb.toFixed(1)} MB` ? `${dbInfo.size_mb.toFixed(1)} MB`
: "—" : '—'
} }
/> />
<StatPair <StatPair
@@ -72,7 +86,7 @@ export function StorageSettingsSection() {
? `${vacuumResult.freed_mb.toFixed(1)} MB freed` ? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
: dbInfo : dbInfo
? `${dbInfo.reclaimable_mb.toFixed(1)} MB` ? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
: "—" : '—'
} }
/> />
</span> </span>
@@ -80,8 +94,8 @@ export function StorageSettingsSection() {
{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.'}
</span> </span>
</> </>
} }
@@ -89,19 +103,19 @@ export function StorageSettingsSection() {
<button <button
className={settingsButtonClass} className={settingsButtonClass}
onClick={() => { onClick={() => {
setVacuuming(true); setVacuuming(true)
setVacuumResult(null); setVacuumResult(null)
void vacuumDatabase() void vacuumDatabase()
.then((result) => { .then((result) => {
setVacuumResult(result); setVacuumResult(result)
setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 }); setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 })
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setVacuuming(false)); .finally(() => setVacuuming(false))
}} }}
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)} disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
> >
{vacuuming ? "Compacting..." : "Compact now"} {vacuuming ? 'Compacting...' : 'Compact now'}
</button> </button>
</SettingsItem> </SettingsItem>
@@ -110,10 +124,9 @@ export function StorageSettingsSection() {
description={ description={
<> <>
<span> <span>
Recreates the visual-embedding index and re-embeds every image in the Recreates the visual-embedding index and re-embeds every image in the background.
background. Use this if semantic or similar-image search reports a Use this if semantic or similar-image search reports a dimension-mismatch error (for
dimension-mismatch error (for example after experimenting with a example after experimenting with a different embedding model).
different embedding model).
</span> </span>
{rebuildIndexResult !== null ? ( {rebuildIndexResult !== null ? (
<span className="mt-2 block text-gray-600">{rebuildIndexResult}</span> <span className="mt-2 block text-gray-600">{rebuildIndexResult}</span>
@@ -124,20 +137,20 @@ export function StorageSettingsSection() {
<button <button
className={settingsButtonClass} className={settingsButtonClass}
onClick={() => { onClick={() => {
setRebuildingIndex(true); setRebuildingIndex(true)
setRebuildIndexResult(null); setRebuildIndexResult(null)
void rebuildSemanticIndex() void rebuildSemanticIndex()
.then((count) => .then((count) =>
setRebuildIndexResult( setRebuildIndexResult(
`Re-queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for embedding.`, `Re-queued ${count.toLocaleString()} image${count === 1 ? '' : 's'} for embedding.`
), )
) )
.catch((error) => setRebuildIndexResult(String(error))) .catch((error) => setRebuildIndexResult(String(error)))
.finally(() => setRebuildingIndex(false)); .finally(() => setRebuildingIndex(false))
}} }}
disabled={rebuildingIndex} disabled={rebuildingIndex}
> >
{rebuildingIndex ? "Rebuilding…" : "Rebuild index"} {rebuildingIndex ? 'Rebuilding…' : 'Rebuild index'}
</button> </button>
</SettingsItem> </SettingsItem>
@@ -145,16 +158,19 @@ export function StorageSettingsSection() {
label="Thumbnail cache" label="Thumbnail cache"
description={ 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>
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"> <span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<StatPair <StatPair
label="Orphaned files" label="Orphaned files"
value={ value={
thumbnailCleanupResult thumbnailCleanupResult
? "0" ? '0'
: thumbnailInfo : thumbnailInfo
? thumbnailInfo.count.toLocaleString() ? thumbnailInfo.count.toLocaleString()
: "—" : '—'
} }
/> />
<StatPair <StatPair
@@ -165,20 +181,20 @@ export function StorageSettingsSection() {
? `${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed` ? `${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed`
: thumbnailInfo : thumbnailInfo
? `${thumbnailInfo.size_mb.toFixed(1)} MB` ? `${thumbnailInfo.size_mb.toFixed(1)} MB`
: "—" : '—'
} }
/> />
</span> </span>
<span className="mt-2 block text-gray-600"> <span className="mt-2 block text-gray-600">
{cleaningThumbnails {cleaningThumbnails
? "Scanning and removing orphaned thumbnails…" ? 'Scanning and removing orphaned thumbnails…'
: thumbnailCleanupResult : thumbnailCleanupResult
? `Removed ${thumbnailCleanupResult.deleted_count.toLocaleString()} file${thumbnailCleanupResult.deleted_count === 1 ? "" : "s"}, freed ${thumbnailCleanupResult.freed_mb.toFixed(1)} MB.` ? `Removed ${thumbnailCleanupResult.deleted_count.toLocaleString()} file${thumbnailCleanupResult.deleted_count === 1 ? '' : 's'}, freed ${thumbnailCleanupResult.freed_mb.toFixed(1)} MB.`
: thumbnailInfo && thumbnailInfo.count === 0 : thumbnailInfo && thumbnailInfo.count === 0
? "No orphaned thumbnails found." ? 'No orphaned thumbnails found.'
: thumbnailInfo && thumbnailInfo.count > 1000 : thumbnailInfo && thumbnailInfo.count > 1000
? "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.'}
</span> </span>
</> </>
} }
@@ -186,21 +202,25 @@ export function StorageSettingsSection() {
<button <button
className={settingsButtonClass} className={settingsButtonClass}
onClick={() => { onClick={() => {
setCleaningThumbnails(true); setCleaningThumbnails(true)
cleanupOrphanedThumbnails() cleanupOrphanedThumbnails()
.then((result) => { .then((result) => {
setThumbnailCleanupResult(result); setThumbnailCleanupResult(result)
setThumbnailInfo(null); setThumbnailInfo(null)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setCleaningThumbnails(false)); .finally(() => setCleaningThumbnails(false))
}} }}
disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)} disabled={
cleaningThumbnails ||
thumbnailCleanupResult !== null ||
(thumbnailInfo !== null && thumbnailInfo.count === 0)
}
> >
{cleaningThumbnails ? "Cleaning…" : "Clean up"} {cleaningThumbnails ? 'Cleaning…' : 'Clean up'}
</button> </button>
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
</div> </div>
); )
} }
@@ -1,20 +1,20 @@
import { getChangelogForVersion } from "../../changelog"; import { getChangelogForVersion } from '../../changelog'
import { useGalleryStore } from "../../store"; import { useGalleryStore } from '../../store'
import { FfmpegStatusRow } from "../onboarding/StepWelcome"; import { FfmpegStatusRow } from '../onboarding/StepWelcome'
import { SettingsGroup, SettingsItem, settingsButtonClass, StatusPill } from "./shared"; import { SettingsGroup, SettingsItem, settingsButtonClass, StatusPill } from './shared'
export function UpdatesSettingsSection() { export function UpdatesSettingsSection() {
const appVersion = useGalleryStore((state) => state.appVersion); const appVersion = useGalleryStore((state) => state.appVersion)
const buildVariant = useGalleryStore((state) => state.buildVariant); const buildVariant = useGalleryStore((state) => state.buildVariant)
const updateStatus = useGalleryStore((state) => state.updateStatus); const updateStatus = useGalleryStore((state) => state.updateStatus)
const updateVersion = useGalleryStore((state) => state.updateVersion); const updateVersion = useGalleryStore((state) => state.updateVersion)
const updateProgress = useGalleryStore((state) => state.updateProgress); const updateProgress = useGalleryStore((state) => state.updateProgress)
const updateError = useGalleryStore((state) => state.updateError); const updateError = useGalleryStore((state) => state.updateError)
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates)
const installUpdate = useGalleryStore((state) => state.installUpdate); const installUpdate = useGalleryStore((state) => state.installUpdate)
const openWhatsNew = useGalleryStore((state) => state.openWhatsNew); const openWhatsNew = useGalleryStore((state) => state.openWhatsNew)
const openOnboarding = useGalleryStore((state) => state.openOnboarding); const openOnboarding = useGalleryStore((state) => state.openOnboarding)
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen); const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen)
return ( return (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
@@ -22,47 +22,55 @@ export function UpdatesSettingsSection() {
<SettingsItem <SettingsItem
label={ label={
<span className="inline-flex items-center gap-2.5"> <span className="inline-flex items-center gap-2.5">
<span>Phokus {appVersion ? `v${appVersion}` : "—"}</span> <span>Phokus {appVersion ? `v${appVersion}` : '—'}</span>
{buildVariant ? ( {buildVariant ? (
<StatusPill tone={buildVariant === "cuda" ? "ready" : "muted"}> <StatusPill tone={buildVariant === 'cuda' ? 'ready' : 'muted'}>
{buildVariant === "cuda" ? "CUDA" : "CPU"} {buildVariant === 'cuda' ? 'CUDA' : 'CPU'}
</StatusPill> </StatusPill>
) : null} ) : null}
{updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? ( {updateStatus === 'available' ||
updateStatus === 'downloading' ||
updateStatus === 'installing' ? (
<StatusPill tone="busy">v{updateVersion} available</StatusPill> <StatusPill tone="busy">v{updateVersion} available</StatusPill>
) : updateStatus === "upToDate" ? ( ) : updateStatus === 'upToDate' ? (
<StatusPill tone="ready">Up to date</StatusPill> <StatusPill tone="ready">Up to date</StatusPill>
) : null} ) : null}
</span> </span>
} }
description={ description={
updateStatus === "error" ? ( updateStatus === 'error' ? (
<span className="text-amber-300/90">Update check failed: {updateError}</span> <span className="text-amber-300/90">Update check failed: {updateError}</span>
) : updateStatus === "downloading" || updateStatus === "installing" ? ( ) : updateStatus === 'downloading' || updateStatus === 'installing' ? (
<span className="block"> <span className="block">
<span className="text-gray-400"> <span className="text-gray-400">
{updateStatus === "installing" {updateStatus === 'installing'
? "Installing update…" ? 'Installing update…'
: updateProgress !== null : updateProgress !== null
? `Downloading update — ${Math.round(updateProgress * 100)}%` ? `Downloading update — ${Math.round(updateProgress * 100)}%`
: "Downloading update…"} : 'Downloading update…'}
</span> </span>
<span className="mt-2 block h-1 overflow-hidden rounded-full bg-white/10"> <span className="mt-2 block h-1 overflow-hidden rounded-full bg-white/10">
<span <span
className={`block h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${ className={`block h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${
updateProgress === null ? "w-full animate-pulse" : "" updateProgress === null ? 'w-full animate-pulse' : ''
}`} }`}
style={updateProgress !== null ? { width: `${Math.round(updateProgress * 100)}%` } : undefined} style={
updateProgress !== null
? { width: `${Math.round(updateProgress * 100)}%` }
: undefined
}
/> />
</span> </span>
<span className="mt-1 block text-gray-600">The app will restart when it finishes.</span> <span className="mt-1 block text-gray-600">
The app will restart when it finishes.
</span>
</span> </span>
) : ( ) : (
"Updates are checked quietly at launch and installed only when you choose." 'Updates are checked quietly at launch and installed only when you choose.'
) )
} }
> >
{updateStatus === "available" ? ( {updateStatus === 'available' ? (
<button <button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25" className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => void installUpdate()} onClick={() => void installUpdate()}
@@ -73,9 +81,13 @@ export function UpdatesSettingsSection() {
<button <button
className={settingsButtonClass} className={settingsButtonClass}
onClick={() => void checkForUpdates()} onClick={() => void checkForUpdates()}
disabled={updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing"} disabled={
updateStatus === 'checking' ||
updateStatus === 'downloading' ||
updateStatus === 'installing'
}
> >
{updateStatus === "checking" ? "Checking..." : "Check for updates"} {updateStatus === 'checking' ? 'Checking...' : 'Check for updates'}
</button> </button>
)} )}
</SettingsItem> </SettingsItem>
@@ -100,8 +112,8 @@ export function UpdatesSettingsSection() {
<button <button
className={settingsButtonClass} className={settingsButtonClass}
onClick={() => { onClick={() => {
setSettingsOpen(false); setSettingsOpen(false)
openOnboarding(); openOnboarding()
}} }}
> >
Show welcome tour Show welcome tour
@@ -109,5 +121,5 @@ export function UpdatesSettingsSection() {
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
</div> </div>
); )
} }
+123 -67
View File
@@ -1,148 +1,204 @@
import { ReactNode } from "react"; import { ReactNode } from 'react'
import { TaggerAcceleration, TaggerModel, TaggingQueueScope } from "../../store"; import { TaggerAcceleration, TaggerModel, TaggingQueueScope } from '../../store'
export type SettingsSection = "general" | "media" | "updates" | "storage" | "workspace"; export type SettingsSection = 'general' | 'media' | 'updates' | 'storage' | 'workspace'
export const SETTINGS_SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [ export const SETTINGS_SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "general", label: "General", detail: "Theme and notifications" }, { id: 'general', label: 'General', detail: 'Theme and notifications' },
{ id: "media", label: "Media", detail: "Playback and slideshow" }, { id: 'media', label: 'Media', detail: 'Playback and slideshow' },
{ id: "updates", label: "Updates & Setup", detail: "Versions, setup, and tour" }, { id: 'updates', label: 'Updates & Setup', detail: 'Versions, setup, and tour' },
{ id: "storage", label: "Storage", detail: "App data and maintenance" }, { id: 'storage', label: 'Storage', detail: 'App data and maintenance' },
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" }, { id: 'workspace', label: 'AI Workspace', detail: 'Tagging models and queue targets' },
]; ]
export function formatBytesShort(bytes: number): string { export function formatBytesShort(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
return `${(bytes / 1024).toFixed(0)} KB`; return `${(bytes / 1024).toFixed(0)} KB`
} }
export function StatusPill({ children, tone }: { children: ReactNode; tone: "ready" | "muted" | "busy" }) { export function StatusPill({
children,
tone,
}: {
children: ReactNode
tone: 'ready' | 'muted' | 'busy'
}) {
const className = const className =
tone === "ready" tone === 'ready'
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700" ? 'border-emerald-400/25 bg-emerald-500/10 text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700'
: tone === "busy" : tone === 'busy'
? "border-sky-400/25 bg-sky-500/10 text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700" ? 'border-sky-400/25 bg-sky-500/10 text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700'
: "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-0.5 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>
)
} }
export function SettingsGroup({ title, description, children }: { export function SettingsGroup({
title: string; title,
description?: string; description,
children: ReactNode; children,
}: {
title: string
description?: string
children: ReactNode
}) { }) {
return ( return (
<section> <section>
<h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">{title}</h4> <h4 className="text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
{description ? <p className="mt-1 text-xs leading-relaxed text-gray-600">{description}</p> : null} {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> <div className="mt-1 divide-y divide-white/[0.05]">{children}</div>
</section> </section>
); )
} }
export function SettingsItem({ label, description, children, vertical = false }: { export function SettingsItem({
label: ReactNode; label,
description?: ReactNode; description,
children?: ReactNode; children,
vertical?: boolean; vertical = false,
}: {
label: ReactNode
description?: ReactNode
children?: ReactNode
vertical?: boolean
}) { }) {
if (vertical) { if (vertical) {
return ( return (
<div className="py-4"> <div className="py-4">
<p className="text-sm text-white">{label}</p> <p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div> : null} {description ? (
<div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div>
) : null}
{children ? <div className="mt-3">{children}</div> : null} {children ? <div className="mt-3">{children}</div> : null}
</div> </div>
); )
} }
return ( return (
<div className="flex items-start justify-between gap-6 py-4"> <div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-white">{label}</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} {description ? (
<div className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">{description}</div>
) : null}
</div> </div>
<div className="shrink-0">{children}</div> <div className="shrink-0">{children}</div>
</div> </div>
); )
} }
export function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) { export function StatPair({
label,
value,
accent = false,
}: {
label: string
value: string
accent?: boolean
}) {
return ( return (
<span className="inline-flex items-baseline gap-2"> <span className="inline-flex items-baseline gap-2">
<span className="text-[10px] uppercase tracking-[0.14em] text-gray-600">{label}</span> <span className="text-[10px] tracking-[0.14em] text-gray-600 uppercase">{label}</span>
<span className={`text-sm font-semibold tabular-nums ${accent ? "text-emerald-300" : "text-white"}`}>{value}</span> <span
className={`text-sm font-semibold tabular-nums ${accent ? 'text-emerald-300' : 'text-white'}`}
>
{value}
</span>
</span> </span>
); )
} }
export function ScopeButton({ scope, current, onSelect, children }: { export function ScopeButton({
scope: TaggingQueueScope; scope,
current: TaggingQueueScope; current,
onSelect: (scope: TaggingQueueScope) => void; onSelect,
children: ReactNode; children,
}: {
scope: TaggingQueueScope
current: TaggingQueueScope
onSelect: (scope: TaggingQueueScope) => void
children: ReactNode
}) { }) {
const active = scope === current; const active = scope === current
return ( return (
<button <button
type="button" type="button"
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 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700" ? 'light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 border-emerald-400/35 bg-emerald-500/15 text-emerald-200'
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white" : 'light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200'
}`} }`}
onClick={() => onSelect(scope)} onClick={() => onSelect(scope)}
> >
{children} {children}
</button> </button>
); )
} }
export function TaggerModelButton({ model, current, onSelect, children }: { export function TaggerModelButton({
model: TaggerModel; model,
current: TaggerModel; current,
onSelect: (model: TaggerModel) => void; onSelect,
children: ReactNode; children,
}: {
model: TaggerModel
current: TaggerModel
onSelect: (model: TaggerModel) => void
children: ReactNode
}) { }) {
const active = model === current; const active = model === current
return ( return (
<button <button
type="button" type="button"
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 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700" ? 'light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 border-emerald-400/35 bg-emerald-500/15 text-emerald-200'
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white" : 'light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200'
}`} }`}
onClick={() => onSelect(model)} onClick={() => onSelect(model)}
> >
{children} {children}
</button> </button>
); )
} }
export function TaggerAccelerationButton({ acceleration, current, onSelect, children }: { export function TaggerAccelerationButton({
acceleration: TaggerAcceleration; acceleration,
current: TaggerAcceleration; current,
onSelect: (acceleration: TaggerAcceleration) => void; onSelect,
children: ReactNode; children,
}: {
acceleration: TaggerAcceleration
current: TaggerAcceleration
onSelect: (acceleration: TaggerAcceleration) => void
children: ReactNode
}) { }) {
const active = acceleration === current; const active = acceleration === current
return ( return (
<button <button
type="button" type="button"
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 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700" ? 'light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 border-emerald-400/35 bg-emerald-500/15 text-emerald-200'
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white" : 'light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200'
}`} }`}
onClick={() => onSelect(acceleration)} onClick={() => onSelect(acceleration)}
> >
{children} {children}
</button> </button>
); )
} }
export const settingsButtonClass = export const settingsButtonClass =
"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 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"; '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 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white'
+81 -73
View File
@@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from 'react'
import { Reorder, useDragControls } from "framer-motion"; import { Reorder, useDragControls } from 'framer-motion'
import { useGalleryStore, type Album } from "../../store"; import { useGalleryStore, type Album } from '../../store'
import { mediaSrc } from "../../lib/mediaSrc"; import { mediaSrc } from '../../lib/mediaSrc'
import { ContextMenu, MenuItem, MenuSeparator } from "../menu"; import { ContextMenu, MenuItem, MenuSeparator } from '../menu'
import { InlineConfirm } from "../InlineConfirm"; import { InlineConfirm } from '../InlineConfirm'
import { InlineRename } from "../InlineRename"; import { InlineRename } from '../InlineRename'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { CheckIcon, PhotoIcon } from "../icons"; import { CheckIcon, PhotoIcon } from '../icons'
export function AlbumItem({ export function AlbumItem({
album, album,
@@ -17,70 +17,72 @@ export function AlbumItem({
onDragStart, onDragStart,
onDragEnd, onDragEnd,
}: { }: {
album: Album; album: Album
manageMode?: boolean; manageMode?: boolean
selectedForManage?: boolean; selectedForManage?: boolean
onToggleManage?: () => void; onToggleManage?: () => void
reorderable?: boolean; reorderable?: boolean
onDragStart?: () => void; onDragStart?: () => void
onDragEnd?: () => void; onDragEnd?: () => void
}) { }) {
const dragControls = useDragControls(); const dragControls = useDragControls()
const viewAlbum = useGalleryStore((state) => state.viewAlbum); const viewAlbum = useGalleryStore((state) => state.viewAlbum)
const renameAlbum = useGalleryStore((state) => state.renameAlbum); const renameAlbum = useGalleryStore((state) => state.renameAlbum)
const deleteAlbum = useGalleryStore((state) => state.deleteAlbum); const deleteAlbum = useGalleryStore((state) => state.deleteAlbum)
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView)
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId); const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId)
const selected = !manageMode && activeView === "album" && selectedAlbumId === album.id; const selected = !manageMode && activeView === 'album' && selectedAlbumId === album.id
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false)
const [confirmingRemoval, setConfirmingRemoval] = useState(false); const [confirmingRemoval, setConfirmingRemoval] = useState(false)
const cover = mediaSrc(album.cover_thumbnail_path); const cover = mediaSrc(album.cover_thumbnail_path)
const row = ( const row = (
<div <div
role={manageMode ? "checkbox" : "button"} role={manageMode ? 'checkbox' : 'button'}
tabIndex={renaming ? -1 : 0} tabIndex={renaming ? -1 : 0}
aria-checked={manageMode ? selectedForManage : undefined} aria-checked={manageMode ? selectedForManage : undefined}
aria-current={!manageMode && selected ? "page" : undefined} aria-current={!manageMode && selected ? 'page' : undefined}
className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${ className={`group relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 transition-all duration-150 ${
selectedForManage selectedForManage
? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50" ? 'bg-blue-500/10 text-white ring-1 ring-blue-400/50 ring-inset'
: selected : selected
? "bg-white/8 text-white" ? 'bg-white/8 text-white'
: "text-gray-500 hover:text-gray-200 hover:bg-white/5" : 'text-gray-500 hover:bg-white/5 hover:text-gray-200'
}`} }`}
onClick={() => { onClick={() => {
if (manageMode) { if (manageMode) {
onToggleManage?.(); onToggleManage?.()
} else if (!renaming) { } else if (!renaming) {
viewAlbum(album.id); viewAlbum(album.id)
} }
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.target !== e.currentTarget) return; if (e.target !== e.currentTarget) return
if (renaming || (e.key !== "Enter" && e.key !== " ")) return; if (renaming || (e.key !== 'Enter' && e.key !== ' ')) return
e.preventDefault(); e.preventDefault()
if (manageMode) { if (manageMode) {
onToggleManage?.(); onToggleManage?.()
} else { } else {
viewAlbum(album.id); viewAlbum(album.id)
} }
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
if (manageMode) return; if (manageMode) return
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY }); setMenu({ x: e.clientX, y: e.clientY })
}} }}
> >
{/* Manage-mode selection checkbox */} {/* Manage-mode selection checkbox */}
{manageMode ? ( {manageMode ? (
<div <div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${ className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
selectedForManage ? "border-blue-400 bg-blue-500 text-white" : "border-white/30 text-transparent" selectedForManage
? 'border-blue-400 bg-blue-500 text-white'
: 'border-white/30 text-transparent'
}`} }`}
> >
<CheckIcon className="h-2.5 w-2.5" strokeWidth={3} /> <CheckIcon className="h-2.5 w-2.5" strokeWidth={3} />
@@ -90,25 +92,28 @@ export function AlbumItem({
{/* Drag handle — hover-revealed, reorders albums */} {/* Drag handle — hover-revealed, reorders albums */}
{reorderable ? ( {reorderable ? (
<Tooltip label="Drag to reorder" anchorToCursor> <Tooltip label="Drag to reorder" anchorToCursor>
<button <button
type="button" type="button"
aria-label={`Reorder ${album.name}`} aria-label={`Reorder ${album.name}`}
className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity hover:text-gray-400 group-hover:opacity-100" className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity group-hover:opacity-100 hover:text-gray-400"
onPointerDown={(e) => { onPointerDown={(e) => {
e.stopPropagation(); e.stopPropagation()
onDragStart?.(); onDragStart?.()
dragControls.start(e); dragControls.start(e)
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor"> <svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" /> <circle cx="3" cy="3" r="1" />
<circle cx="3" cy="6" r="1" /><circle cx="9" cy="6" r="1" /> <circle cx="9" cy="3" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" /> <circle cx="3" cy="6" r="1" />
</svg> <circle cx="9" cy="6" r="1" />
</button> <circle cx="3" cy="9" r="1" />
</Tooltip> <circle cx="9" cy="9" r="1" />
) : null} </svg>
</button>
</Tooltip>
) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */} {/* Cover thumbnail — distinguishes albums from folder rows */}
<div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10"> <div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10">
@@ -129,16 +134,21 @@ export function AlbumItem({
onClose={() => setRenaming(false)} onClose={() => setRenaming(false)}
/> />
) : ( ) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}> <div
className={`truncate text-[13px] leading-tight font-medium ${selected ? 'text-white' : ''}`}
>
{album.name} {album.name}
</div> </div>
)} )}
<div className="text-[11px] text-gray-600 mt-0.5">{album.image_count.toLocaleString()}</div> <div className="mt-0.5 text-[11px] text-gray-600">{album.image_count.toLocaleString()}</div>
</div> </div>
{!renaming && confirmingRemoval ? ( {!renaming && confirmingRemoval ? (
<InlineConfirm <InlineConfirm
onConfirm={() => { void deleteAlbum(album.id); setConfirmingRemoval(false); }} onConfirm={() => {
void deleteAlbum(album.id)
setConfirmingRemoval(false)
}}
onCancel={() => setConfirmingRemoval(false)} onCancel={() => setConfirmingRemoval(false)}
/> />
) : null} ) : null}
@@ -151,7 +161,7 @@ export function AlbumItem({
</ContextMenu> </ContextMenu>
) : null} ) : null}
</div> </div>
); )
if (reorderable) { if (reorderable) {
return ( return (
@@ -164,13 +174,11 @@ export function AlbumItem({
dragElastic={0.08} dragElastic={0.08}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
layout layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }} transition={{ layout: { type: 'spring', stiffness: 520, damping: 38, mass: 0.55 } }}
> >
{row} {row}
</Reorder.Item> </Reorder.Item>
); )
} }
return row; return row
} }
+93 -82
View File
@@ -1,82 +1,83 @@
import { useRef, useState } from "react"; import { useRef, useState } from 'react'
import { Reorder } from "framer-motion"; import { Reorder } from 'framer-motion'
import { useGalleryStore } from "../../store"; import { useGalleryStore } from '../../store'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { PlusIcon } from "../icons"; import { PlusIcon } from '../icons'
import { AlbumItem } from "./AlbumItem"; import { AlbumItem } from './AlbumItem'
import { useAlbumOrdering } from "./useAlbumOrdering"; import { useAlbumOrdering } from './useAlbumOrdering'
export function AlbumSection() { export function AlbumSection() {
const albums = useGalleryStore((state) => state.albums); const albums = useGalleryStore((state) => state.albums)
const createAlbum = useGalleryStore((state) => state.createAlbum); const createAlbum = useGalleryStore((state) => state.createAlbum)
const deleteAlbums = useGalleryStore((state) => state.deleteAlbums); const deleteAlbums = useGalleryStore((state) => state.deleteAlbums)
const reorderAlbums = useGalleryStore((state) => state.reorderAlbums); const reorderAlbums = useGalleryStore((state) => state.reorderAlbums)
const [creatingAlbum, setCreatingAlbum] = useState(false); const [creatingAlbum, setCreatingAlbum] = useState(false)
const [createAlbumPending, setCreateAlbumPending] = useState(false); const [createAlbumPending, setCreateAlbumPending] = useState(false)
const [newAlbumName, setNewAlbumName] = useState(""); const [newAlbumName, setNewAlbumName] = useState('')
const newAlbumInputRef = useRef<HTMLInputElement>(null); const newAlbumInputRef = useRef<HTMLInputElement>(null)
const [manageAlbums, setManageAlbums] = useState(false); const [manageAlbums, setManageAlbums] = useState(false)
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(new Set()); const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(new Set())
const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false); const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false)
const { finishAlbumReorder, handleAlbumReorder, orderedAlbums, setDraggingAlbum } = useAlbumOrdering(albums, reorderAlbums); const { finishAlbumReorder, handleAlbumReorder, orderedAlbums, setDraggingAlbum } =
useAlbumOrdering(albums, reorderAlbums)
const startCreatingAlbum = () => { const startCreatingAlbum = () => {
setCreatingAlbum(true); setCreatingAlbum(true)
setNewAlbumName(""); setNewAlbumName('')
setTimeout(() => newAlbumInputRef.current?.focus(), 0); setTimeout(() => newAlbumInputRef.current?.focus(), 0)
}; }
const handleCreateAlbum = async () => { const handleCreateAlbum = async () => {
const name = newAlbumName.trim(); const name = newAlbumName.trim()
if (!name) { if (!name) {
setCreatingAlbum(false); setCreatingAlbum(false)
return; return
} }
if (createAlbumPending) return; if (createAlbumPending) return
setCreateAlbumPending(true); setCreateAlbumPending(true)
try { try {
const album = await createAlbum(name); const album = await createAlbum(name)
setNewAlbumName(""); setNewAlbumName('')
setCreatingAlbum(false); setCreatingAlbum(false)
useGalleryStore.getState().viewAlbum(album.id); useGalleryStore.getState().viewAlbum(album.id)
} finally { } finally {
setCreateAlbumPending(false); setCreateAlbumPending(false)
} }
}; }
const exitManageAlbums = () => { const exitManageAlbums = () => {
setManageAlbums(false); setManageAlbums(false)
setManageSelectedIds(new Set()); setManageSelectedIds(new Set())
setConfirmingAlbumDelete(false); setConfirmingAlbumDelete(false)
}; }
const toggleManageSelected = (albumId: number) => { const toggleManageSelected = (albumId: number) => {
setManageSelectedIds((prev) => { setManageSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev)
if (next.has(albumId)) next.delete(albumId); if (next.has(albumId)) next.delete(albumId)
else next.add(albumId); else next.add(albumId)
return next; return next
}); })
setConfirmingAlbumDelete(false); setConfirmingAlbumDelete(false)
}; }
const handleDeleteSelectedAlbums = async () => { const handleDeleteSelectedAlbums = async () => {
const ids = Array.from(manageSelectedIds); const ids = Array.from(manageSelectedIds)
if (ids.length === 0) return; if (ids.length === 0) return
await deleteAlbums(ids); await deleteAlbums(ids)
exitManageAlbums(); exitManageAlbums()
}; }
return ( return (
<div className="shrink-0 border-t-2 border-white/[0.08] bg-white/[0.015]"> <div className="shrink-0 border-t-2 border-white/[0.08] bg-white/[0.015]">
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1"> <div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600"> <span className="text-[10px] font-semibold tracking-[0.15em] text-gray-600 uppercase">
{manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"} {manageAlbums ? `${manageSelectedIds.size} selected` : 'Albums'}
</span> </span>
{manageAlbums ? ( {manageAlbums ? (
<button <button
onClick={exitManageAlbums} onClick={exitManageAlbums}
className="rounded px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500 transition-colors hover:text-gray-200" className="rounded px-1 text-[10px] font-semibold tracking-wider text-gray-500 uppercase transition-colors hover:text-gray-200"
> >
Done Done
</button> </button>
@@ -84,24 +85,33 @@ export function AlbumSection() {
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{albums.length > 0 ? ( {albums.length > 0 ? (
<Tooltip label="Manage albums"> <Tooltip label="Manage albums">
<button <button
onClick={() => setManageAlbums(true)} onClick={() => setManageAlbums(true)}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200" className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} className="h-3.5 w-3.5"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> fill="none"
</svg> viewBox="0 0 24 24"
</button> stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.75}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
</Tooltip> </Tooltip>
) : null} ) : null}
<Tooltip label="New album"> <Tooltip label="New album">
<button <button
onClick={startCreatingAlbum} onClick={startCreatingAlbum}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200" className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
> >
<PlusIcon className="h-3.5 w-3.5" /> <PlusIcon className="h-3.5 w-3.5" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
)} )}
@@ -112,8 +122,9 @@ export function AlbumSection() {
{confirmingAlbumDelete ? ( {confirmingAlbumDelete ? (
<div className="rounded-lg border border-red-500/25 bg-red-500/[0.06] p-2"> <div className="rounded-lg border border-red-500/25 bg-red-500/[0.06] p-2">
<p className="mb-2 text-[11px] leading-relaxed text-gray-400"> <p className="mb-2 text-[11px] leading-relaxed text-gray-400">
Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? "" : "s"}? Your images stay in Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? '' : 's'}? Your
the library only the album{manageSelectedIds.size === 1 ? "" : "s"} {manageSelectedIds.size === 1 ? "is" : "are"} removed. images stay in the library only the album{manageSelectedIds.size === 1 ? '' : 's'}{' '}
{manageSelectedIds.size === 1 ? 'is' : 'are'} removed.
</p> </p>
<div className="flex justify-end gap-1.5"> <div className="flex justify-end gap-1.5">
<button <button
@@ -136,45 +147,45 @@ export function AlbumSection() {
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300" className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={() => onClick={() =>
setManageSelectedIds((prev) => setManageSelectedIds((prev) =>
prev.size === albums.length ? new Set() : new Set(albums.map((a) => a.id)), prev.size === albums.length ? new Set() : new Set(albums.map((a) => a.id))
) )
} }
> >
{manageSelectedIds.size === albums.length ? "Deselect all" : "Select all"} {manageSelectedIds.size === albums.length ? 'Deselect all' : 'Select all'}
</button> </button>
<button <button
className="rounded-md border border-red-400/25 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40" className="rounded-md border border-red-400/25 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingAlbumDelete(true)} onClick={() => setConfirmingAlbumDelete(true)}
disabled={manageSelectedIds.size === 0} disabled={manageSelectedIds.size === 0}
> >
Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ""} Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ''}
</button> </button>
</div> </div>
)} )}
</div> </div>
) : null} ) : null}
<div className="max-h-52 overflow-y-auto px-2 pb-2 space-y-px"> <div className="max-h-52 space-y-px overflow-y-auto px-2 pb-2">
{creatingAlbum ? ( {creatingAlbum ? (
<form <form
className="flex gap-1 px-1 py-1" className="flex gap-1 px-1 py-1"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault()
void handleCreateAlbum(); void handleCreateAlbum()
}} }}
> >
<input <input
ref={newAlbumInputRef} ref={newAlbumInputRef}
className="min-w-0 flex-1 rounded border border-white/10 bg-white/10 px-1.5 py-1 text-[13px] text-white outline-none ring-1 ring-blue-500/40 placeholder-gray-600" className="min-w-0 flex-1 rounded border border-white/10 bg-white/10 px-1.5 py-1 text-[13px] text-white placeholder-gray-600 ring-1 ring-blue-500/40 outline-none"
placeholder="Album name…" placeholder="Album name…"
value={newAlbumName} value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)} onChange={(e) => setNewAlbumName(e.target.value)}
disabled={createAlbumPending} disabled={createAlbumPending}
onKeyDown={(e) => { onKeyDown={(e) => {
if (createAlbumPending) return; if (createAlbumPending) return
if (e.key === "Escape") { if (e.key === 'Escape') {
setCreatingAlbum(false); setCreatingAlbum(false)
setNewAlbumName(""); setNewAlbumName('')
} }
}} }}
onBlur={() => void handleCreateAlbum()} onBlur={() => void handleCreateAlbum()}
@@ -217,5 +228,5 @@ export function AlbumSection() {
)} )}
</div> </div>
</div> </div>
); )
} }
+163 -112
View File
@@ -1,12 +1,12 @@
import { useState, type MouseEvent } from "react"; import { useState, type MouseEvent } from 'react'
import { Reorder, useDragControls } from "framer-motion"; import { Reorder, useDragControls } from 'framer-motion'
import { open } from "@tauri-apps/plugin-dialog"; import { open } from '@tauri-apps/plugin-dialog'
import { useGalleryStore, type Folder, type IndexProgress } from "../../store"; import { useGalleryStore, type Folder, type IndexProgress } from '../../store'
import { ContextMenu, MenuItem, MenuSeparator } from "../menu"; import { ContextMenu, MenuItem, MenuSeparator } from '../menu'
import { InlineConfirm } from "../InlineConfirm"; import { InlineConfirm } from '../InlineConfirm'
import { InlineRename } from "../InlineRename"; import { InlineRename } from '../InlineRename'
import { Tooltip } from "../Tooltip"; import { Tooltip } from '../Tooltip'
import { CloseIcon, FolderIcon } from "../icons"; import { CloseIcon, FolderIcon } from '../icons'
export function FolderItem({ export function FolderItem({
folder, folder,
@@ -18,109 +18,128 @@ export function FolderItem({
onDragEnd, onDragEnd,
onKeyboardMove, onKeyboardMove,
}: { }: {
folder: Folder; folder: Folder
selected: boolean; selected: boolean
progress: IndexProgress | undefined; progress: IndexProgress | undefined
customOrdering: boolean; customOrdering: boolean
dragging: boolean; dragging: boolean
onDragStart: (pointerY: number) => void; onDragStart: (pointerY: number) => void
onDragEnd: () => void; onDragEnd: () => void
onKeyboardMove: (direction: -1 | 1) => void; onKeyboardMove: (direction: -1 | 1) => void
}) { }) {
const dragControls = useDragControls(); const dragControls = useDragControls()
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore(); const {
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds); selectFolder,
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused); removeFolder,
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id]); reindexFolder,
const isMuted = mutedFolderIds.includes(folder.id); renameFolder,
updateFolderPath,
toggleMutedFolder,
} = useGalleryStore()
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds)
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused)
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id])
const isMuted = mutedFolderIds.includes(folder.id)
// "Fully paused" only when every worker for this folder is paused. // "Fully paused" only when every worker for this folder is paused.
const isPausedAll = !!folderWorkers && const isPausedAll =
folderWorkers.thumbnail && folderWorkers.metadata && folderWorkers.embedding && folderWorkers.tagging; !!folderWorkers &&
const isIndexing = progress && !progress.done; folderWorkers.thumbnail &&
const isMissing = !!folder.scan_error && !isIndexing; folderWorkers.metadata &&
folderWorkers.embedding &&
folderWorkers.tagging
const isIndexing = progress && !progress.done
const isMissing = !!folder.scan_error && !isIndexing
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false)
const [confirmingRemoval, setConfirmingRemoval] = useState(false); const [confirmingRemoval, setConfirmingRemoval] = useState(false)
const handleContextMenu = (e: MouseEvent) => { const handleContextMenu = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY }); setContextMenu({ x: e.clientX, y: e.clientY })
}; }
const handleLocateFolder = async () => { const handleLocateFolder = async () => {
const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` }); const picked = await open({
if (picked && typeof picked === "string") { directory: true,
await updateFolderPath(folder.id, picked); multiple: false,
title: `Locate "${folder.name}"`,
})
if (picked && typeof picked === 'string') {
await updateFolderPath(folder.id, picked)
} }
}; }
return ( return (
<Reorder.Item <Reorder.Item
as="div" as="div"
value={folder.id} value={folder.id}
drag={customOrdering ? "y" : false} drag={customOrdering ? 'y' : false}
dragControls={dragControls} dragControls={dragControls}
dragListener={false} dragListener={false}
dragElastic={0.08} dragElastic={0.08}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
layout layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }} transition={{ layout: { type: 'spring', stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? "z-20" : ""}`} className={`relative z-0 ${dragging ? 'z-20' : ''}`}
style={{ position: "relative" }} style={{ position: 'relative' }}
> >
<div <div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${ className={`group relative flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 transition-all duration-150 ${
selected selected ? 'bg-white/8 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-gray-200'
? "bg-white/8 text-white" } ${dragging ? 'scale-[1.02] bg-white/[0.11] text-white shadow-xl ring-1 shadow-black/25 ring-white/15' : ''}`}
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
} ${dragging ? "scale-[1.02] bg-white/[0.11] text-white shadow-xl shadow-black/25 ring-1 ring-white/15" : ""}`}
onClick={() => !renaming && selectFolder(folder.id)} onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
{customOrdering ? ( {customOrdering ? (
<Tooltip label={`Drag to reorder ${folder.name}`} followCursor delay={1000}> <Tooltip label={`Drag to reorder ${folder.name}`} followCursor delay={1000}>
<button <button
type="button" type="button"
aria-label={`Reorder ${folder.name}`} aria-label={`Reorder ${folder.name}`}
className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${ className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${
dragging dragging
? "cursor-grabbing bg-white/10 text-gray-300" ? 'cursor-grabbing bg-white/10 text-gray-300'
: "cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400" : 'cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400'
}`} }`}
onPointerDown={(event) => { onPointerDown={(event) => {
event.stopPropagation(); event.stopPropagation()
onDragStart(event.clientY); onDragStart(event.clientY)
dragControls.start(event); dragControls.start(event)
}} }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
onKeyboardMove(event.key === "ArrowUp" ? -1 : 1); onKeyboardMove(event.key === 'ArrowUp' ? -1 : 1)
}} }}
> >
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor"> <svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" /> <circle cx="3" cy="3" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" /> <circle cx="9" cy="3" r="1" />
</svg> <circle cx="3" cy="9" r="1" />
</button> <circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip> </Tooltip>
) : null} ) : null}
{isMissing ? ( {isMissing ? (
<span className="shrink-0 text-amber-400"> <span className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} <path
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg> </svg>
</span> </span>
) : ( ) : (
<FolderIcon className="w-3.5 h-3.5 shrink-0" /> <FolderIcon className="h-3.5 w-3.5 shrink-0" />
)} )}
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
{renaming ? ( {renaming ? (
<InlineRename <InlineRename
name={folder.name} name={folder.name}
@@ -128,74 +147,101 @@ export function FolderItem({
onClose={() => setRenaming(false)} onClose={() => setRenaming(false)}
/> />
) : ( ) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}> <div
className={`truncate text-[13px] leading-tight font-medium ${selected ? 'text-white' : ''}`}
>
{folder.name} {folder.name}
</div> </div>
)} )}
{isIndexing ? ( {isIndexing ? (
<> <>
<div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div> <div className="mt-0.5 text-[11px] text-gray-600">
<div className="h-px bg-white/10 rounded mt-1.5 overflow-hidden"> {progress.indexed}/{progress.total}
</div>
<div className="mt-1.5 h-px overflow-hidden rounded bg-white/10">
<div <div
className="h-full bg-blue-500 transition-all duration-300" className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%` }} style={{
width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%`,
}}
/> />
</div> </div>
</> </>
) : ( ) : (
<div className="text-[11px] text-gray-600 mt-0.5">{folder.image_count.toLocaleString()}</div> <div className="mt-0.5 text-[11px] text-gray-600">
{folder.image_count.toLocaleString()}
</div>
)} )}
</div> </div>
{/* Hover action buttons */} {/* Hover action buttons */}
{!renaming && ( {!renaming &&
confirmingRemoval ? ( (confirmingRemoval ? (
<InlineConfirm <InlineConfirm
onConfirm={() => { void removeFolder(folder.id); setConfirmingRemoval(false); }} onConfirm={() => {
void removeFolder(folder.id)
setConfirmingRemoval(false)
}}
onCancel={() => setConfirmingRemoval(false)} onCancel={() => setConfirmingRemoval(false)}
/> />
) : ( ) : (
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip label="Reindex" anchorToCursor> <Tooltip label="Reindex" anchorToCursor>
<button <button
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors" className="rounded p-1 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-300"
onClick={(e) => { e.stopPropagation(); void reindexFolder(folder.id); }} onClick={(e) => {
e.stopPropagation()
void reindexFolder(folder.id)
}}
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} <path
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.75}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove from app" anchorToCursor> <Tooltip label="Remove from app" anchorToCursor>
<button <button
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors" className="rounded p-1 text-gray-600 transition-colors hover:bg-red-500/10 hover:text-red-400"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }} onClick={(e) => {
e.stopPropagation()
setConfirmingRemoval(true)
}}
> >
<CloseIcon className="w-3 h-3" strokeWidth={1.75} /> <CloseIcon className="h-3 w-3" strokeWidth={1.75} />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
) ))}
)}
</div> </div>
{isMissing && ( {isMissing && (
<div className="mx-2 mb-1 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20"> <div className="mx-2 mb-1 rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2">
<p className="text-[11px] text-amber-400 font-medium mb-1.5">Folder not found</p> <p className="mb-1.5 text-[11px] font-medium text-amber-400">Folder not found</p>
<p className="text-[10px] text-gray-500 mb-2 leading-snug"> <p className="mb-2 text-[10px] leading-snug text-gray-500">
This folder may have been moved or renamed. Locate it to resume, or remove it from the app. This folder may have been moved or renamed. Locate it to resume, or remove it from the
app.
</p> </p>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 hover:text-amber-200 transition-colors" className="flex-1 rounded-md bg-amber-500/20 px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-amber-500/30 hover:text-amber-200"
onClick={(e) => { e.stopPropagation(); void handleLocateFolder(); }} onClick={(e) => {
e.stopPropagation()
void handleLocateFolder()
}}
> >
Locate Folder Locate Folder
</button> </button>
<button <button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-white/5 text-gray-400 hover:bg-red-500/15 hover:text-red-400 transition-colors" className="flex-1 rounded-md bg-white/5 px-2 py-1 text-[10px] font-medium text-gray-400 transition-colors hover:bg-red-500/15 hover:text-red-400"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }} onClick={(e) => {
e.stopPropagation()
setConfirmingRemoval(true)
}}
> >
Remove Remove
</button> </button>
@@ -204,24 +250,29 @@ export function FolderItem({
)} )}
{contextMenu && ( {contextMenu && (
<ContextMenu x={contextMenu.x} y={contextMenu.y} size="sm" onClose={() => setContextMenu(null)}> <ContextMenu
x={contextMenu.x}
y={contextMenu.y}
size="sm"
onClose={() => setContextMenu(null)}
>
<MenuItem label="Reindex" onSelect={() => void reindexFolder(folder.id)} /> <MenuItem label="Reindex" onSelect={() => void reindexFolder(folder.id)} />
<MenuItem label="Rename" onSelect={() => setRenaming(true)} /> <MenuItem label="Rename" onSelect={() => setRenaming(true)} />
<MenuItem <MenuItem
label={isPausedAll ? "Resume background work" : "Pause background work"} label={isPausedAll ? 'Resume background work' : 'Pause background work'}
onSelect={() => setAllWorkersPaused(folder.id, !isPausedAll)} onSelect={() => setAllWorkersPaused(folder.id, !isPausedAll)}
/> />
<MenuItem <MenuItem
label={isMuted ? "Unmute notifications" : "Mute notifications"} label={isMuted ? 'Unmute notifications' : 'Mute notifications'}
onSelect={() => toggleMutedFolder(folder.id)} onSelect={() => toggleMutedFolder(folder.id)}
/> />
{folder.scan_error ? <MenuItem label="Locate Folder" onSelect={() => void handleLocateFolder()} /> : null} {folder.scan_error ? (
<MenuItem label="Locate Folder" onSelect={() => void handleLocateFolder()} />
) : null}
<MenuSeparator /> <MenuSeparator />
<MenuItem label="Remove from app" danger onSelect={() => setConfirmingRemoval(true)} /> <MenuItem label="Remove from app" danger onSelect={() => setConfirmingRemoval(true)} />
</ContextMenu> </ContextMenu>
)} )}
</Reorder.Item> </Reorder.Item>
); )
} }
+21 -19
View File
@@ -1,14 +1,14 @@
import { Reorder } from "framer-motion"; import { Reorder } from 'framer-motion'
import { useGalleryStore } from "../../store"; import { useGalleryStore } from '../../store'
import { Dropdown } from "../menu"; import { Dropdown } from '../menu'
import { FolderItem } from "./FolderItem"; import { FolderItem } from './FolderItem'
import { useFolderOrdering } from "./useFolderOrdering"; import { useFolderOrdering } from './useFolderOrdering'
export function LibrarySection() { export function LibrarySection() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const indexingProgress = useGalleryStore((state) => state.indexingProgress); const indexingProgress = useGalleryStore((state) => state.indexingProgress)
const reorderFolders = useGalleryStore((state) => state.reorderFolders); const reorderFolders = useGalleryStore((state) => state.reorderFolders)
const { const {
customOrdering, customOrdering,
@@ -22,13 +22,15 @@ export function LibrarySection() {
pointerYRef, pointerYRef,
setDraggedFolderId, setDraggedFolderId,
setLibrarySort, setLibrarySort,
} = useFolderOrdering(folders, reorderFolders); } = useFolderOrdering(folders, reorderFolders)
return ( return (
<> <>
{folders.length > 0 && ( {folders.length > 0 && (
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1"> <div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span> <span className="text-[10px] font-semibold tracking-[0.15em] text-gray-600 uppercase">
Libraries
</span>
<Dropdown <Dropdown
value={librarySort} value={librarySort}
onChange={setLibrarySort} onChange={setLibrarySort}
@@ -36,9 +38,9 @@ export function LibrarySection() {
trigger="compact" trigger="compact"
panelClassName="min-w-0" panelClassName="min-w-0"
options={[ options={[
{ value: "az", label: "A-Z" }, { value: 'az', label: 'A-Z' },
{ value: "za", label: "Z-A" }, { value: 'za', label: 'Z-A' },
{ value: "custom", label: "Custom" }, { value: 'custom', label: 'Custom' },
]} ]}
/> />
</div> </div>
@@ -51,10 +53,10 @@ export function LibrarySection() {
values={displayedFolders.map((folder) => folder.id)} values={displayedFolders.map((folder) => folder.id)}
onReorder={customOrdering ? handleReorder : () => {}} onReorder={customOrdering ? handleReorder : () => {}}
layoutScroll layoutScroll
className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0" className="min-h-0 flex-1 space-y-px overflow-y-auto px-2 pb-2"
> >
{folders.length === 0 ? ( {folders.length === 0 ? (
<p className="text-gray-700 text-xs px-3 py-6 text-center leading-relaxed"> <p className="px-3 py-6 text-center text-xs leading-relaxed text-gray-700">
Add a folder to get started Add a folder to get started
</p> </p>
) : ( ) : (
@@ -67,8 +69,8 @@ export function LibrarySection() {
customOrdering={customOrdering} customOrdering={customOrdering}
dragging={draggedFolderId === folder.id} dragging={draggedFolderId === folder.id}
onDragStart={(pointerY) => { onDragStart={(pointerY) => {
pointerYRef.current = pointerY; pointerYRef.current = pointerY
setDraggedFolderId(folder.id); setDraggedFolderId(folder.id)
}} }}
onDragEnd={finishReorder} onDragEnd={finishReorder}
onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)} onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)}
@@ -77,5 +79,5 @@ export function LibrarySection() {
)} )}
</Reorder.Group> </Reorder.Group>
</> </>
); )
} }

Some files were not shown because too many files have changed in this diff Show More