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.
@@ -76,4 +76,4 @@ export default defineConfig({
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
})
+69 -69
View File
@@ -1,100 +1,100 @@
import { useEffect } from "react";
import { useGalleryStore } from "./store";
import { Sidebar } from "./components/Sidebar";
import { BackgroundTasks } from "./components/BackgroundTasks";
import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox";
import { ExploreView } from "./components/ExploreView";
import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal";
import { FolderPickerModal } from "./components/FolderPickerModal";
import { UpdateToast } from "./components/UpdateToast";
import { WhatsNewToast } from "./components/WhatsNewToast";
import { WhatsNewModal } from "./components/WhatsNewModal";
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
import { DemoPanel } from "./components/DemoPanel";
import { initializeNotifications } from "./notifications";
import { useEffect } from 'react'
import { useGalleryStore } from './store'
import { Sidebar } from './components/Sidebar'
import { BackgroundTasks } from './components/BackgroundTasks'
import { Toolbar } from './components/Toolbar'
import { Gallery } from './components/Gallery'
import { Lightbox } from './components/Lightbox'
import { ExploreView } from './components/ExploreView'
import { DuplicateFinder } from './components/DuplicateFinder'
import { Timeline } from './components/Timeline'
import { TitleBar } from './components/TitleBar'
import { SettingsModal } from './components/SettingsModal'
import { FolderPickerModal } from './components/FolderPickerModal'
import { UpdateToast } from './components/UpdateToast'
import { WhatsNewToast } from './components/WhatsNewToast'
import { WhatsNewModal } from './components/WhatsNewModal'
import { OnboardingOverlay } from './components/onboarding/OnboardingOverlay'
import { DemoPanel } from './components/DemoPanel'
import { initializeNotifications } from './notifications'
export default function App() {
const loadFolders = useGalleryStore((state) => state.loadFolders);
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const loadAlbums = useGalleryStore((state) => state.loadAlbums);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus);
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted);
const initWhatsNew = useGalleryStore((state) => state.initWhatsNew);
const activeView = useGalleryStore((state) => state.activeView);
const loadFolders = useGalleryStore((state) => state.loadFolders)
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress)
const loadImages = useGalleryStore((state) => state.loadImages)
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus)
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus)
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel)
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache)
const loadAlbums = useGalleryStore((state) => state.loadAlbums)
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds)
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused)
const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist)
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress)
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion)
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates)
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus)
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted)
const initWhatsNew = useGalleryStore((state) => state.initWhatsNew)
const activeView = useGalleryStore((state) => state.activeView)
useEffect(() => {
void initializeNotifications();
void loadMutedFolderIds();
void loadNotificationsPaused();
void loadWorkerPausesPersist();
void loadFfmpegStatus();
void loadOnboardingCompleted();
void initializeNotifications()
void loadMutedFolderIds()
void loadNotificationsPaused()
void loadWorkerPausesPersist()
void loadFfmpegStatus()
void loadOnboardingCompleted()
// 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.
void loadAppVersion().then(() => initWhatsNew());
void loadAppVersion().then(() => initWhatsNew())
// Quiet launch check — dev builds have no signed artifacts to update to.
if (import.meta.env.PROD) {
void checkForUpdates({ quiet: true });
void checkForUpdates({ quiet: true })
}
loadFolders().then(async () => {
void loadBackgroundJobProgress();
void loadCaptionModelStatus();
void loadTaggerModel();
void loadTaggerModelStatus();
void loadDuplicateScanCache();
await loadAlbums();
await loadImages(true);
if (import.meta.env.MODE === "ui") {
const { applyMockScenario } = await import("./dev/applyMockScenario");
applyMockScenario();
void loadBackgroundJobProgress()
void loadCaptionModelStatus()
void loadTaggerModel()
void loadTaggerModelStatus()
void loadDuplicateScanCache()
await loadAlbums()
await loadImages(true)
if (import.meta.env.MODE === 'ui') {
const { applyMockScenario } = await import('./dev/applyMockScenario')
applyMockScenario()
}
});
let unlisten: (() => void) | undefined;
})
let unlisten: (() => void) | undefined
subscribeToProgress().then((fn) => {
unlisten = fn;
});
unlisten = fn
})
return () => {
unlisten?.();
};
}, []);
unlisten?.()
}
}, [])
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 */}
<TitleBar />
{/* Main app content below the title bar */}
<div className="flex flex-1 min-h-0">
<div className="flex min-h-0 flex-1">
<Sidebar />
<main className="flex-1 flex flex-col min-w-0">
{activeView === "timeline" ? (
<main className="flex min-w-0 flex-1 flex-col">
{activeView === 'timeline' ? (
<>
<Toolbar />
<BackgroundTasks />
<Timeline />
</>
) : activeView === "explore" ? (
) : activeView === 'explore' ? (
<>
<BackgroundTasks />
<ExploreView />
</>
) : activeView === "duplicates" ? (
) : activeView === 'duplicates' ? (
<>
<BackgroundTasks />
<DuplicateFinder />
@@ -118,5 +118,5 @@ export default function App() {
<OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />}
</div>
);
)
}
+55 -51
View File
@@ -2,113 +2,117 @@
// 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
// 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 {
/** 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. */
body: string;
body: string
}
export interface ChangelogSection {
/** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */
title: string;
items: ChangelogItem[];
title: string
items: ChangelogItem[]
}
export interface ChangelogEntry {
version: string;
date: string | null;
sections: ChangelogSection[];
version: string
date: string | null
sections: ChangelogSection[]
}
// "## [0.1.1] — 2026-06-23" / "## [Unreleased]"
const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/;
const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/
// "### Added"
const SECTION_HEADING = /^###\s+(.+?)\s*$/;
const SECTION_HEADING = /^###\s+(.+?)\s*$/
// "- bullet text"
const BULLET = /^[-*]\s+(.*)$/;
const BULLET = /^[-*]\s+(.*)$/
// 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.)
const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/;
const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/
function toItem(text: string): ChangelogItem {
const collapsed = text.replace(/\s+/g, " ").trim();
const match = collapsed.match(LEAD);
const collapsed = text.replace(/\s+/g, ' ').trim()
const match = collapsed.match(LEAD)
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[] {
const lines = raw.split(/\r?\n/);
const entries: ChangelogEntry[] = [];
const lines = raw.split(/\r?\n/)
const entries: ChangelogEntry[] = []
let entry: ChangelogEntry | null = null;
let section: ChangelogSection | null = null;
let buffer: string[] = [];
let entry: ChangelogEntry | null = null
let section: ChangelogSection | null = null
let buffer: string[] = []
const flushItem = () => {
if (section && buffer.length > 0) {
section.items.push(toItem(buffer.join(" ")));
section.items.push(toItem(buffer.join(' ')))
}
buffer = []
}
buffer = [];
};
for (const line of lines) {
const versionMatch = line.match(VERSION_HEADING);
const versionMatch = line.match(VERSION_HEADING)
if (versionMatch) {
flushItem();
section = null;
entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] };
entries.push(entry);
continue;
flushItem()
section = null
entry = {
version: versionMatch[1].trim(),
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).
if (!entry) continue;
if (!entry) continue
const sectionMatch = line.match(SECTION_HEADING);
const sectionMatch = line.match(SECTION_HEADING)
if (sectionMatch) {
flushItem();
section = { title: sectionMatch[1].trim(), items: [] };
entry.sections.push(section);
continue;
flushItem()
section = { title: sectionMatch[1].trim(), items: [] }
entry.sections.push(section)
continue
}
const bulletMatch = line.match(BULLET);
const bulletMatch = line.match(BULLET)
if (bulletMatch) {
flushItem();
buffer.push(bulletMatch[1]);
continue;
flushItem()
buffer.push(bulletMatch[1])
continue
}
if (line.trim() === "") {
if (line.trim() === '') {
// A blank line ends a (possibly wrapped) multi-line bullet.
flushItem();
continue;
flushItem()
continue
}
// Indented continuation of the current wrapped bullet.
if (buffer.length > 0) {
buffer.push(line.trim());
buffer.push(line.trim())
}
}
flushItem();
return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) }));
flushItem()
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 {
if (!version) return null;
if (!version) return null
// 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.
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.
if (normalized.toLowerCase() === "unreleased") return null;
return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null;
if (normalized.toLowerCase() === 'unreleased') return null
return ENTRIES.find((e) => e.version.replace(/^v/, '') === normalized) ?? null
}
+17 -17
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useGalleryStore } from "../store";
import { useState } from 'react'
import { useGalleryStore } from '../store'
/**
* Album list plus a create-new-album form. The host decides what picking
@@ -7,23 +7,23 @@ import { useGalleryStore } from "../store";
* picked immediately.
*/
export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise<void> | void }) {
const albums = useGalleryStore((state) => state.albums);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const [creating, setCreating] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const albums = useGalleryStore((state) => state.albums)
const createAlbum = useGalleryStore((state) => state.createAlbum)
const [creating, setCreating] = useState(false)
const [newAlbumName, setNewAlbumName] = useState('')
const handleCreate = async () => {
const name = newAlbumName.trim();
if (!name || creating) return;
setCreating(true);
const name = newAlbumName.trim()
if (!name || creating) return
setCreating(true)
try {
const album = await createAlbum(name);
setNewAlbumName("");
await onPick(album.id);
const album = await createAlbum(name)
setNewAlbumName('')
await onPick(album.id)
} finally {
setCreating(false);
setCreating(false)
}
}
};
return (
<>
@@ -46,8 +46,8 @@ export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise<v
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2"
onSubmit={(event) => {
event.preventDefault();
void handleCreate();
event.preventDefault()
void handleCreate()
}}
>
<input
@@ -66,5 +66,5 @@ export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise<v
</button>
</form>
</>
);
)
}
+72 -56
View File
@@ -1,68 +1,83 @@
import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore, type WorkerKey } from "../store";
import { BackgroundTaskSummary } from "./backgroundTasks/BackgroundTaskSummary";
import { ExpandedTaskPanel } from "./backgroundTasks/ExpandedTaskPanel";
import { buildDuplicateScanTask, buildFolderTasks, taskHasTerminalFailure, taskProgress } from "./backgroundTasks/taskModel";
import type { BackgroundTask, FailedWorkerItem } from "./backgroundTasks/types";
import { useEffect, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useGalleryStore, type WorkerKey } from '../store'
import { BackgroundTaskSummary } from './backgroundTasks/BackgroundTaskSummary'
import { ExpandedTaskPanel } from './backgroundTasks/ExpandedTaskPanel'
import {
buildDuplicateScanTask,
buildFolderTasks,
taskHasTerminalFailure,
taskProgress,
} from './backgroundTasks/taskModel'
import type { BackgroundTask, FailedWorkerItem } from './backgroundTasks/types'
export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const workerPaused = useGalleryStore((state) => state.workerPaused);
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused);
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const folders = useGalleryStore((state) => state.folders)
const indexingProgress = useGalleryStore((state) => state.indexingProgress)
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress)
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings)
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs)
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging)
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning)
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress)
const workerPaused = useGalleryStore((state) => state.workerPaused)
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates)
const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused)
const [expanded, setExpanded] = useState(false)
const [dismissed, setDismissed] = useState<Record<number, string>>({})
const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<
Record<number, FailedWorkerItem[]>
>({})
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>(
{}
)
useEffect(() => {
void loadWorkerStates();
}, [folders, loadWorkerStates]);
void loadWorkerStates()
}, [folders, loadWorkerStates])
const failedEmbeddingCounts = useMemo(
() =>
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(
() =>
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(() => {
if (!expanded) return;
if (!expanded) return
for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_embedding_images", {
invoke<FailedWorkerItem[]>('get_failed_embedding_images', {
folderId: Number(folderId),
})
.then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
.catch(() => undefined)
}
}
for (const [folderId, count] of Object.entries(failedTaggingCounts)) {
if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_tagging_images", {
invoke<FailedWorkerItem[]>('get_failed_tagging_images', {
folderId: Number(folderId),
})
.then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
.catch(() => undefined)
}
}
}, [expanded, failedEmbeddingCounts, failedTaggingCounts]);
}, [expanded, failedEmbeddingCounts, failedTaggingCounts])
const folderTasks = useMemo(
() =>
@@ -73,37 +88,38 @@ export function BackgroundTasks() {
mediaJobProgress,
workerPaused,
}),
[dismissed, folders, indexingProgress, mediaJobProgress, workerPaused],
);
[dismissed, folders, indexingProgress, mediaJobProgress, workerPaused]
)
const duplicateScanTask = useMemo(
() => buildDuplicateScanTask(duplicateScanning, duplicateScanProgress),
[duplicateScanning, duplicateScanProgress],
);
const allTasks = duplicateScanTask ? [duplicateScanTask, ...folderTasks] : folderTasks;
[duplicateScanning, duplicateScanProgress]
)
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) => {
setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker));
};
setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker))
}
const dismissTask = (task: BackgroundTask) => {
if (task.id < 0) return;
setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot }));
setExpanded(false);
};
if (task.id < 0) return
setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot }))
setExpanded(false)
}
const retryTask = (task: BackgroundTask) => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
};
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id)
if (task.hasFailedTagging) void queueTaggingJobs(task.id)
}
const primary = allTasks[0];
const hasFailed = folderTasks.some(taskHasTerminalFailure);
const barProgress = taskProgress(primary);
const primary = allTasks[0]
const hasFailed = folderTasks.some(taskHasTerminalFailure)
const barProgress = taskProgress(primary)
return (
<div className="shrink-0 border-b border-white/[0.06]">
@@ -135,5 +151,5 @@ export function BackgroundTasks() {
/>
) : null}
</div>
);
)
}
+68 -56
View File
@@ -1,66 +1,67 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
import { BulkAlbumPopover } from "./bulk/BulkAlbumPopover";
import { BulkDeleteConfirm } from "./bulk/BulkDeleteConfirm";
import { BulkRatingPopover } from "./bulk/BulkRatingPopover";
import { BulkSelectionSummary } from "./bulk/BulkSelectionSummary";
import { BulkTagPopover } from "./bulk/BulkTagPopover";
import { type BulkPanel } from "./bulk/types";
import { useDismissable } from "./menu";
import { Tooltip } from "./Tooltip";
import { CloseIcon } from "./icons";
import { useEffect, useRef, useState } from 'react'
import { useGalleryStore } from '../store'
import { BulkAlbumPopover } from './bulk/BulkAlbumPopover'
import { BulkDeleteConfirm } from './bulk/BulkDeleteConfirm'
import { BulkRatingPopover } from './bulk/BulkRatingPopover'
import { BulkSelectionSummary } from './bulk/BulkSelectionSummary'
import { BulkTagPopover } from './bulk/BulkTagPopover'
import { type BulkPanel } from './bulk/types'
import { useDismissable } from './menu'
import { Tooltip } from './Tooltip'
import { CloseIcon } from './icons'
export function BulkActionBar() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
const selectedIds = useGalleryStore((state) => state.gallerySelectedIds);
const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection);
const selectAllGallery = useGalleryStore((state) => state.selectAllGallery);
const loadedCount = useGalleryStore((state) => state.loadedCount);
const totalImages = useGalleryStore((state) => state.totalImages);
const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite);
const bulkSetRating = useGalleryStore((state) => state.bulkSetRating);
const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected);
const activeView = useGalleryStore((state) => state.activeView);
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum);
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size)
const selectedIds = useGalleryStore((state) => state.gallerySelectedIds)
const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection)
const selectAllGallery = useGalleryStore((state) => state.selectAllGallery)
const loadedCount = useGalleryStore((state) => state.loadedCount)
const totalImages = useGalleryStore((state) => state.totalImages)
const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite)
const bulkSetRating = useGalleryStore((state) => state.bulkSetRating)
const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected)
const activeView = useGalleryStore((state) => state.activeView)
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId)
const addToAlbum = useGalleryStore((state) => state.addToAlbum)
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum)
const [panel, setPanel] = useState<BulkPanel>(null);
const [deleting, setDeleting] = useState(false);
const barRef = useRef<HTMLDivElement>(null);
const [panel, setPanel] = useState<BulkPanel>(null)
const [deleting, setDeleting] = useState(false)
const barRef = useRef<HTMLDivElement>(null)
// 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.
useEffect(() => {
if (selectedCount === 0) setPanel(null);
}, [selectedCount]);
if (selectedCount === 0) setPanel(null)
}, [selectedCount])
if (selectedCount === 0) return null;
if (selectedCount === 0) return null
const ids = Array.from(selectedIds);
const inAlbumView = activeView === "album" && selectedAlbumId !== null;
const togglePanel = (next: Exclude<BulkPanel, null>) => setPanel((current) => (current === next ? null : next));
const ids = Array.from(selectedIds)
const inAlbumView = activeView === 'album' && selectedAlbumId !== null
const togglePanel = (next: Exclude<BulkPanel, null>) =>
setPanel((current) => (current === next ? null : next))
const handleDelete = async () => {
setDeleting(true);
setDeleting(true)
try {
await bulkDeleteSelected();
await bulkDeleteSelected()
} finally {
setDeleting(false);
setPanel(null);
setDeleting(false)
setPanel(null)
}
}
};
const handlePickAlbum = async (albumId: number) => {
await addToAlbum(albumId, ids);
setPanel(null);
};
await addToAlbum(albumId, ids)
setPanel(null)
}
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 btnActive = `${btn} bg-white/10 text-white`;
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 btnActive = `${btn} bg-white/10 text-white`
return (
<div
@@ -78,17 +79,25 @@ export function BulkActionBar() {
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<button className={panel === "tag" ? btnActive : btnIdle} onClick={() => togglePanel("tag")}>
<button
className={panel === 'tag' ? btnActive : btnIdle}
onClick={() => togglePanel('tag')}
>
Tag
</button>
{panel === "tag" ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
{panel === 'tag' ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
</div>
<div className="relative">
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}>
<button
className={panel === 'rating' ? btnActive : btnIdle}
onClick={() => togglePanel('rating')}
>
Rating
</button>
{panel === "rating" ? <BulkRatingPopover onSetRating={bulkSetRating} onClose={() => setPanel(null)} /> : null}
{panel === 'rating' ? (
<BulkRatingPopover onSetRating={bulkSetRating} onClose={() => setPanel(null)} />
) : null}
</div>
<Tooltip label="Mark as favorite" followCursor>
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)}>
@@ -96,10 +105,13 @@ export function BulkActionBar() {
</button>
</Tooltip>
<div className="relative">
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}>
<button
className={panel === 'album' ? btnActive : btnIdle}
onClick={() => togglePanel('album')}
>
Add to album
</button>
{panel === "album" ? <BulkAlbumPopover onPick={handlePickAlbum} /> : null}
{panel === 'album' ? <BulkAlbumPopover onPick={handlePickAlbum} /> : null}
</div>
{inAlbumView ? (
@@ -117,17 +129,17 @@ export function BulkActionBar() {
<Tooltip label="Delete files from disk" followCursor>
<button
className={
panel === "delete"
panel === 'delete'
? `${btn} bg-red-500/15 text-red-300`
: `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`
}
onClick={() => togglePanel("delete")}
onClick={() => togglePanel('delete')}
disabled={deleting}
>
{deleting ? "Deleting…" : "Delete"}
{deleting ? 'Deleting…' : 'Delete'}
</button>
</Tooltip>
{panel === "delete" ? (
{panel === 'delete' ? (
<BulkDeleteConfirm
deleting={deleting}
selectedCount={selectedCount}
@@ -145,5 +157,5 @@ export function BulkActionBar() {
</button>
</Tooltip>
</div>
);
)
}
+77 -51
View File
@@ -1,69 +1,82 @@
import { useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { useDismissable } from "./menu";
import { Tooltip } from "./Tooltip";
import { useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from '../store'
import { useDismissable } from './menu'
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
// distance filter matches against — not a hard bucket.
const SWATCHES: { name: string; rgb: Rgb }[] = [
{ name: "Red", rgb: [226, 59, 59] },
{ name: "Orange", rgb: [232, 134, 46] },
{ name: "Yellow", rgb: [242, 207, 46] },
{ name: "Green", rgb: [76, 175, 80] },
{ name: "Teal", rgb: [31, 182, 166] },
{ name: "Blue", rgb: [59, 125, 216] },
{ name: "Purple", rgb: [139, 92, 246] },
{ name: "Pink", rgb: [236, 72, 153] },
{ name: "Brown", rgb: [139, 90, 43] },
{ name: "Black", rgb: [26, 26, 26] },
{ name: "White", rgb: [245, 245, 245] },
{ name: "Gray", rgb: [154, 160, 166] },
];
{ name: 'Red', rgb: [226, 59, 59] },
{ name: 'Orange', rgb: [232, 134, 46] },
{ name: 'Yellow', rgb: [242, 207, 46] },
{ name: 'Green', rgb: [76, 175, 80] },
{ name: 'Teal', rgb: [31, 182, 166] },
{ name: 'Blue', rgb: [59, 125, 216] },
{ name: 'Purple', rgb: [139, 92, 246] },
{ name: 'Pink', rgb: [236, 72, 153] },
{ name: 'Brown', rgb: [139, 90, 43] },
{ name: 'Black', rgb: [26, 26, 26] },
{ name: 'White', rgb: [245, 245, 245] },
{ name: 'Gray', rgb: [154, 160, 166] },
]
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 {
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 {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
const n = parseInt(hex.slice(1), 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
export function ColorFilter() {
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const colorBackfill = useGalleryStore((state) => state.colorBackfill);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const colorFilter = useGalleryStore((state) => state.colorFilter)
const setColorFilter = useGalleryStore((state) => state.setColorFilter)
const colorBackfill = useGalleryStore((state) => state.colorBackfill)
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const isActive = colorFilter !== null;
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb));
const isActive = colorFilter !== null
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb))
// Collapse the panel when clicking elsewhere or pressing Escape.
useDismissable(ref, () => setOpen(false), open);
useDismissable(ref, () => setOpen(false), open)
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
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
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)}
aria-label="Filter by color"
>
<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}
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" />
<path
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="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
@@ -86,42 +99,49 @@ export function ColorFilter() {
initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }}
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"
transition={{ duration: 0.14, ease: 'easeOut' }}
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">
{SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb);
const active = rgbEquals(colorFilter, swatch.rgb)
return (
<Tooltip label= {swatch.name} followCursor>
<Tooltip label={swatch.name} followCursor>
<button
key={swatch.name}
aria-label={`Filter by ${swatch.name}`}
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'
: 'border-white/15 hover:scale-110'
}`}
style={{ backgroundColor: toHex(swatch.rgb) }}
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. */}
<label
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'
: 'border-white/15 hover:scale-110'
}`}
style={
isCustom
? { backgroundColor: toHex(colorFilter as Rgb) }
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
: {
background:
'conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)',
}
}
>
<input
type="color"
className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
value={colorFilter ? toHex(colorFilter) : '#3b7dd8'}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/>
</label>
@@ -129,14 +149,20 @@ export function ColorFilter() {
</div>
{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 ? (
<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">
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
sampling {colorBackfill.processed.toLocaleString()}/
{colorBackfill.total.toLocaleString()}
</span>
</Tooltip>
) : <span />}
) : (
<span />
)}
{isActive ? (
<Tooltip label="Clear colour filter" anchorToCursor>
<button
@@ -153,5 +179,5 @@ export function ColorFilter() {
) : null}
</AnimatePresence>
</div>
);
)
}
+83 -73
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { FolderJobProgress, useGalleryStore } from "../store";
import { useEffect, useRef, useState } from 'react'
import { FolderJobProgress, useGalleryStore } from '../store'
// Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise
// too transient (or unreachable) to capture: the background-worker pipeline,
@@ -25,7 +25,7 @@ function emptyProgress(folderId: number): FolderJobProgress {
tagging_pending: 0,
tagging_ready: 0,
tagging_failed: 0,
};
}
}
// 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 },
// Late stage: embeddings done, tagging running.
{ 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() {
const folders = useGalleryStore((state) => state.folders);
const appVersion = useGalleryStore((state) => state.appVersion);
const [open, setOpen] = useState(false);
const downloadTimer = useRef<number | null>(null);
const folders = useGalleryStore((state) => state.folders)
const appVersion = useGalleryStore((state) => state.appVersion)
const [open, setOpen] = useState(false)
const downloadTimer = useRef<number | null>(null)
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "d") {
event.preventDefault();
setOpen((value) => !value);
if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'd') {
event.preventDefault()
setOpen((value) => !value)
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const stopTimer = () => {
if (downloadTimer.current !== null) {
window.clearInterval(downloadTimer.current);
downloadTimer.current = null;
window.clearInterval(downloadTimer.current)
downloadTimer.current = null
}
}
};
// Stop any running download animation when the panel unmounts.
useEffect(() => stopTimer, []);
useEffect(() => stopTimer, [])
const injectBusy = () => {
const progress: Record<number, FolderJobProgress> = {};
const progress: Record<number, FolderJobProgress> = {}
folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => {
progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] };
});
useGalleryStore.setState({ mediaJobProgress: progress });
};
progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] }
})
useGalleryStore.setState({ mediaJobProgress: progress })
}
const injectSingleEmbedding = () => {
const folder = folders[0];
if (!folder) return;
const folder = folders[0]
if (!folder) return
useGalleryStore.setState({
mediaJobProgress: {
[folder.id]: { ...emptyProgress(folder.id), embedding_ready: 27, embedding_pending: 23 },
},
});
};
})
}
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} });
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} })
// --- Updater flow ---------------------------------------------------------
// 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.
const updateAvailable = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "available",
updateStatus: 'available',
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
};
})
}
// Indeterminate pulse, then climb 0 → 100%, then flip to "installing".
const simulateDownload = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "downloading",
updateStatus: 'downloading',
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
let progress = 0;
})
let progress = 0
window.setTimeout(() => {
downloadTimer.current = window.setInterval(() => {
progress = Math.min(progress + 0.07, 1);
useGalleryStore.setState({ updateProgress: progress });
progress = Math.min(progress + 0.07, 1)
useGalleryStore.setState({ updateProgress: progress })
if (progress >= 1) {
stopTimer();
useGalleryStore.setState({ updateStatus: "installing" });
stopTimer()
useGalleryStore.setState({ updateStatus: 'installing' })
}
}, 200)
}, 700)
}
}, 200);
}, 700);
};
const updateInstalling = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "installing",
updateStatus: 'installing',
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: 1,
updateDismissed: false,
});
};
})
}
const updateError = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "error",
updateError: "Could not reach the update server (connection timed out).",
updateStatus: 'error',
updateError: 'Could not reach the update server (connection timed out).',
updateDismissed: false,
});
};
})
}
const updateUpToDate = () => {
stopTimer();
useGalleryStore.setState({ updateStatus: "upToDate", updateVersion: null, updateError: null });
};
stopTimer()
useGalleryStore.setState({ updateStatus: 'upToDate', updateVersion: null, updateError: null })
}
const resetUpdate = () => {
stopTimer();
stopTimer()
useGalleryStore.setState({
updateStatus: "idle",
updateStatus: 'idle',
updateVersion: null,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
};
})
}
// --- What's New flow ------------------------------------------------------
// 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.
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 =
"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 =
"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 (
<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">
<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>
<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">
Inject a frozen worker-bar state, hide this panel, then screenshot.
</p>
@@ -204,7 +210,9 @@ export function DemoPanel() {
<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">
Drives the real toast (bottom-right) + Settings About row. Install is inert here.
</p>
@@ -231,9 +239,11 @@ export function DemoPanel() {
<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">
Post-update greeting for v{appVersion ?? "—"}, sourced from the bundled changelog.
Post-update greeting for v{appVersion ?? ''}, sourced from the bundled changelog.
</p>
<div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={showWhatsNewToast}>
@@ -247,5 +257,5 @@ export function DemoPanel() {
</button>
</div>
</div>
);
)
}
+52 -46
View File
@@ -1,58 +1,65 @@
import { useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useGalleryStore } from "../store";
import { DuplicateScanEmptyState, DuplicateScanIntroState, DuplicateScanLoadingState } from "./duplicateFinder/DuplicateFinderEmptyStates";
import { DuplicateFinderHeader } from "./duplicateFinder/DuplicateFinderHeader";
import { DuplicateGroupCard } from "./duplicateFinder/DuplicateGroupCard";
import { duplicateProgressLabel } from "./duplicateFinder/format";
import { useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useGalleryStore } from '../store'
import {
DuplicateScanEmptyState,
DuplicateScanIntroState,
DuplicateScanLoadingState,
} from './duplicateFinder/DuplicateFinderEmptyStates'
import { DuplicateFinderHeader } from './duplicateFinder/DuplicateFinderHeader'
import { DuplicateGroupCard } from './duplicateFinder/DuplicateGroupCard'
import { duplicateProgressLabel } from './duplicateFinder/format'
export function DuplicateFinder() {
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError);
const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning);
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates);
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection);
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups);
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups)
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning)
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress)
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError)
const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning)
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds)
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates)
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection)
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups)
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates)
const [deleting, setDeleting] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleteResult, setDeleteResult] = useState<string | null>(null)
// 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
// copies wraps across rows), so heights are measured dynamically.
const scrollRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: duplicateGroups.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 220,
overscan: 4,
});
})
const selectedCount = duplicateSelectedIds.size;
const hasResults = duplicateGroups.length > 0;
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
const selectedCount = duplicateSelectedIds.size
const hasResults = duplicateGroups.length > 0
const hasScanned =
hasResults ||
duplicateLastScanned !== null ||
(!duplicateScanning && duplicateScanProgress !== null)
const handleDelete = async () => {
setDeleting(true);
setConfirmingDelete(false);
setDeleteResult(null);
setDeleting(true)
setConfirmingDelete(false)
setDeleteResult(null)
try {
const deleted = await deleteSelectedDuplicates();
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`);
const deleted = await deleteSelectedDuplicates()
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? '' : 's'}.`)
} catch (e) {
setDeleteResult(String(e));
setDeleteResult(String(e))
} finally {
setDeleting(false);
setDeleting(false)
}
}
};
const progressLabel = duplicateProgressLabel(duplicateScanProgress);
const progressLabel = duplicateProgressLabel(duplicateScanProgress)
return (
<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)}
onDelete={handleDelete}
onScan={() => {
setDeleteResult(null);
void scanDuplicates(selectedFolderId);
setDeleteResult(null)
void scanDuplicates(selectedFolderId)
}}
onSelectKeepFirstAll={selectKeepFirstAllGroups}
selectedCount={selectedCount}
@@ -88,32 +95,31 @@ export function DuplicateFinder() {
<DuplicateScanEmptyState />
) : (
<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) => {
const group = duplicateGroups[virtualItem.index];
if (!group) return null;
const group = duplicateGroups[virtualItem.index]
if (!group) return null
return (
<div
key={group.file_hash}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
position: 'absolute',
top: 0,
left: 0,
width: "100%",
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
paddingBottom: 16,
}}
>
<DuplicateGroupCard group={group} />
</div>
);
)
})}
</div>
</div>
)}
</div>
);
)
}
+75 -61
View File
@@ -1,51 +1,55 @@
import { useEffect } from "react";
import { useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ClusterCloud } from "./explore/ClusterCloud";
import { ExploreLoadingPanel } from "./explore/ExploreLoadingPanel";
import { TagAtlas, TAG_ATLAS_MAX_VISIBLE } from "./explore/TagAtlas";
import { TagManageList } from "./explore/TagManageList";
import { useEffect } from 'react'
import { useGalleryStore } from '../store'
import { FolderScopeDropdown } from './FolderScopeDropdown'
import { ClusterCloud } from './explore/ClusterCloud'
import { ExploreLoadingPanel } from './explore/ExploreLoadingPanel'
import { TagAtlas, TAG_ATLAS_MAX_VISIBLE } from './explore/TagAtlas'
import { TagManageList } from './explore/TagManageList'
export function ExploreView() {
const exploreMode = useGalleryStore((state) => state.exploreMode);
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries);
const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading);
const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters);
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags);
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
const searchForTag = useGalleryStore((state) => state.searchForTag);
const renameTag = useGalleryStore((state) => state.renameTag);
const deleteTag = useGalleryStore((state) => state.deleteTag);
const resetAiTags = useGalleryStore((state) => state.resetAiTags);
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const exploreMode = useGalleryStore((state) => state.exploreMode)
const setExploreMode = useGalleryStore((state) => state.setExploreMode)
const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries)
const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading)
const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters)
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries)
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading)
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags)
const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags)
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster)
const searchForTag = useGalleryStore((state) => state.searchForTag)
const renameTag = useGalleryStore((state) => state.renameTag)
const deleteTag = useGalleryStore((state) => state.deleteTag)
const resetAiTags = useGalleryStore((state) => state.resetAiTags)
const folders = useGalleryStore((state) => state.folders)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
// Manage mode lives in the store so it can be opened from elsewhere (Settings).
const manageTags = useGalleryStore((state) => state.tagManagerOpen);
const setManageTags = useGalleryStore((state) => state.setTagManagerOpen);
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
const manageTags = useGalleryStore((state) => state.tagManagerOpen)
const setManageTags = useGalleryStore((state) => state.setTagManagerOpen)
const handleDeleteTag = async (tag: string) => {
await deleteTag(tag)
}
const tagManagerScopeLabel =
selectedFolderId === null
? "all media"
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "the current folder";
? 'all media'
: (folders.find((folder) => folder.id === selectedFolderId)?.name ?? 'the current folder')
const handleResetAiTags = async () => {
const count = await resetAiTags(selectedFolderId);
await loadExploreTags({ force: true });
return count;
};
const count = await resetAiTags(selectedFolderId)
await loadExploreTags({ force: true })
return count
}
useEffect(() => {
if (exploreMode === "visual") void loadVisualClusters();
else void loadExploreTags();
}, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags]);
if (exploreMode === 'visual') void loadVisualClusters()
else void loadExploreTags()
}, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags])
const loading = exploreMode === "visual" ? visualClusterLoading : exploreTagLoading;
const hasEntries = exploreMode === "visual" ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0;
const entryCount = exploreMode === "visual" ? visualClusterEntries.length : exploreTagEntries.length;
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE);
const loading = exploreMode === 'visual' ? visualClusterLoading : exploreTagLoading
const hasEntries =
exploreMode === 'visual' ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0
const entryCount =
exploreMode === 'visual' ? visualClusterEntries.length : exploreTagEntries.length
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE)
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]">
@@ -57,48 +61,54 @@ export function ExploreView() {
<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">
{loading
? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
? exploreMode === 'visual'
? 'Computing visual clusters…'
: 'Loading tags…'
: hasEntries
? exploreMode === "visual"
? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
? exploreMode === 'visual'
? `${entryCount} cluster${entryCount !== 1 ? 's' : ''} — click any to open`
: manageTags
? `${entryCount} tag${entryCount !== 1 ? "s" : ""} available to manage`
? `${entryCount} tag${entryCount !== 1 ? 's' : ''} available to manage`
: visibleTagCount < entryCount
? `${visibleTagCount} of ${entryCount} tags shown — click any to search`
: `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search`
: exploreMode === "visual"
? "No clusters — images need embeddings first"
: "No tags — run the AI tagger or add tags manually"}
: `${entryCount} tag${entryCount !== 1 ? 's' : ''} — click any to search`
: exploreMode === 'visual'
? 'No clusters — images need embeddings first'
: 'No tags — run the AI tagger or add tags manually'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{exploreMode === "tags" && hasEntries ? (
{exploreMode === 'tags' && hasEntries ? (
<button
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
manageTags
? "border-white/15 bg-white/10 text-white"
: "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300"
? 'border-white/15 bg-white/10 text-white'
: 'border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300'
}`}
onClick={() => setManageTags(!manageTags)}
>
{manageTags ? "Done" : "Manage"}
{manageTags ? 'Done' : 'Manage'}
</button>
) : null}
<FolderScopeDropdown />
<div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
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
</button>
<button
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
</button>
@@ -112,12 +122,12 @@ export function ExploreView() {
) : !hasEntries ? (
<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">
{exploreMode === "visual"
? "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."}
{exploreMode === 'visual'
? '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.'}
</p>
</div>
) : exploreMode === "visual" ? (
) : exploreMode === 'visual' ? (
<div className="relative flex min-h-0 flex-1 flex-col">
<ClusterCloud entries={visualClusterEntries} onOpen={showVisualCluster} />
</div>
@@ -133,8 +143,12 @@ export function ExploreView() {
/>
</div>
) : (
<TagAtlas entries={exploreTagEntries} onSearch={searchForTag} loadRelatedTags={loadRelatedTags} />
<TagAtlas
entries={exploreTagEntries}
onSearch={searchForTag}
loadRelatedTags={loadRelatedTags}
/>
)}
</div>
);
)
}
+58 -44
View File
@@ -1,23 +1,23 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { Tooltip } from "./Tooltip";
import { CloseIcon } from "./icons";
import { FolderRow } from "./folderPicker/FolderRow";
import { StagedFoldersPanel } from "./folderPicker/StagedFoldersPanel";
import { StatusLine } from "./folderPicker/StatusLine";
import { normalizePath } from "./folderPicker/pathUtils";
import { useFolderPicker } from "./folderPicker/useFolderPicker";
import { useVirtualizer } from '@tanstack/react-virtual'
import { Tooltip } from './Tooltip'
import { CloseIcon } from './icons'
import { FolderRow } from './folderPicker/FolderRow'
import { StagedFoldersPanel } from './folderPicker/StagedFoldersPanel'
import { StatusLine } from './folderPicker/StatusLine'
import { normalizePath } from './folderPicker/pathUtils'
import { useFolderPicker } from './folderPicker/useFolderPicker'
export function FolderPickerModal() {
const folderPicker = useFolderPicker();
const folderPicker = useFolderPicker()
const virtualizer = useVirtualizer({
count: folderPicker.entries.length,
getScrollElement: () => folderPicker.scrollRef.current,
estimateSize: () => 48,
overscan: 8,
});
})
if (!folderPicker.folderPickerOpen) return null;
if (!folderPicker.folderPickerOpen) return null
return (
<div
@@ -25,19 +25,21 @@ export function FolderPickerModal() {
onClick={() => folderPicker.setFolderPickerOpen(false)}
>
<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()}
>
<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="min-w-0">
<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>
<Tooltip label="Close folder picker" anchorToCursor>
<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)}
>
<CloseIcon className="h-4 w-4" />
@@ -51,7 +53,7 @@ export function FolderPickerModal() {
<div className="mb-4 flex items-center gap-2">
<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)}
disabled={!folderPicker.listing?.current}
>
@@ -61,15 +63,17 @@ export function FolderPickerModal() {
<form
className="flex min-w-0 flex-1 items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
folderPicker.navigateToAddress();
event.preventDefault()
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
ref={folderPicker.addressInputRef}
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}
onChange={(event) => folderPicker.updateAddressDraft(event.target.value)}
placeholder="Paste or type a folder path"
@@ -77,27 +81,30 @@ export function FolderPickerModal() {
/>
<button
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}
>
Go
</button>
</form>
) : (
<div
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"
>
<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">
<nav className="flex min-w-0 items-center gap-1 overflow-hidden">
{folderPicker.breadcrumbs.map((crumb, index) => (
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null}
<Tooltip label={crumb.path ?? "Roots"} anchorToCursor>
<span
key={`${crumb.path ?? 'root'}-${index}`}
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
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) => {
event.stopPropagation();
folderPicker.setCurrentPath(crumb.path);
event.stopPropagation()
folderPicker.setCurrentPath(crumb.path)
}}
>
{crumb.label}
@@ -108,7 +115,7 @@ export function FolderPickerModal() {
</nav>
<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}
aria-label="Edit folder path"
/>
@@ -129,28 +136,35 @@ export function FolderPickerModal() {
</div>
{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}
</div>
) : 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 ? (
<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 ? (
<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
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const entry = folderPicker.entries[virtualItem.index];
const normalized = normalizePath(entry.path);
const entry = folderPicker.entries[virtualItem.index]
const normalized = normalizePath(entry.path)
return (
<div
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={{
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
@@ -164,7 +178,7 @@ export function FolderPickerModal() {
onNavigate={() => folderPicker.setCurrentPath(entry.path)}
/>
</div>
);
)
})}
</div>
)}
@@ -178,7 +192,7 @@ export function FolderPickerModal() {
/>
</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="min-w-0 flex-1">
<StatusLine results={folderPicker.results} />
@@ -187,23 +201,23 @@ export function FolderPickerModal() {
<div className="flex shrink-0 items-center gap-2">
<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)}
>
Cancel
</button>
<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()}
disabled={folderPicker.stagedPaths.length === 0 || folderPicker.adding}
>
{folderPicker.adding ? "Adding..." : `Add ${folderPicker.stagedPaths.length}`}
{folderPicker.adding ? 'Adding...' : `Add ${folderPicker.stagedPaths.length}`}
</button>
</div>
</div>
</footer>
</div>
</div>
);
)
}
+22 -12
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useGalleryStore } from "../store";
import { Dropdown, DropdownOption } from "./menu";
import { useMemo } from 'react'
import { useGalleryStore } from '../store'
import { Dropdown, DropdownOption } from './menu'
/**
* 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.
*/
export function FolderScopeDropdown() {
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope);
const folders = useGalleryStore((state) => state.folders)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope)
const options = useMemo<DropdownOption<number | null>[]>(
() => [
{ value: null, label: "All Media" },
{ value: null, label: 'All Media' },
...folders.map((folder) => ({
value: folder.id,
label: folder.name,
hint: <span className="tabular-nums">{folder.image_count.toLocaleString()}</span>,
})),
],
[folders],
);
[folders]
)
return (
<Dropdown
@@ -36,10 +36,20 @@ export function FolderScopeDropdown() {
triggerClassName="max-w-56"
panelClassName="min-w-52 max-h-80 overflow-y-auto"
triggerIcon={
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
<svg
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>
}
/>
);
)
}
+71 -64
View File
@@ -1,50 +1,54 @@
import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
import { BulkActionBar } from "./BulkActionBar";
import { ImageContextMenu } from "./ImageContextMenu";
import { GalleryEmptyState, GalleryLoadingState } from "./gallery/GalleryEmptyState";
import { ImageTile } from "./gallery/ImageTile";
import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from '../store'
import { BulkActionBar } from './BulkActionBar'
import { ImageContextMenu } from './ImageContextMenu'
import { GalleryEmptyState, GalleryLoadingState } from './gallery/GalleryEmptyState'
import { ImageTile } from './gallery/ImageTile'
const GAP = 6;
const GAP = 6
export function Gallery() {
const images = useGalleryStore((state) => state.images);
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages);
const openImage = useGalleryStore((state) => state.openImage);
const totalImages = useGalleryStore((state) => state.totalImages);
const loadingImages = useGalleryStore((state) => state.loadingImages);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const search = useGalleryStore((state) => state.search);
const collectionTitle = useGalleryStore((state) => state.collectionTitle);
const imageLoadError = useGalleryStore((state) => state.imageLoadError);
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey);
const isSimilarResults = collectionTitle === "Similar Images";
const parsedSearch = parseSearchValue(search);
const images = useGalleryStore((state) => state.images)
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages)
const openImage = useGalleryStore((state) => state.openImage)
const totalImages = useGalleryStore((state) => state.totalImages)
const loadingImages = useGalleryStore((state) => state.loadingImages)
const zoomPreset = useGalleryStore((state) => state.zoomPreset)
const search = useGalleryStore((state) => state.search)
const collectionTitle = useGalleryStore((state) => state.collectionTitle)
const imageLoadError = useGalleryStore((state) => state.imageLoadError)
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey)
const isSimilarResults = collectionTitle === 'Similar Images'
const parsedSearch = parseSearchValue(search)
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
const parentRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
image: ImageRecord
} | null>(null)
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const el = parentRef.current
if (!el) return
setContainerWidth(el.clientWidth)
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
setContainerWidth(entries[0].contentRect.width)
})
ro.observe(el)
return () => ro.disconnect()
}, [])
const tileSize = tileSizeForZoom(zoomPreset);
const tileSize = tileSizeForZoom(zoomPreset)
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
const rowCount = Math.ceil(images.length / cols);
[containerWidth, tileSize]
)
const rowCount = Math.ceil(images.length / cols)
const estimateSize = useCallback(() => tileSize + GAP, [tileSize]);
const estimateSize = useCallback(() => tileSize + GAP, [tileSize])
const virtualizer = useVirtualizer({
count: rowCount,
@@ -52,36 +56,39 @@ export function Gallery() {
estimateSize,
overscan: 3,
paddingStart: GAP,
});
})
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
virtualizer.measure()
}, [cols, virtualizer])
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
parentRef.current?.scrollTo({ top: 0, left: 0 })
}, [galleryScrollResetKey])
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
const el = parentRef.current
if (!el) return
if (el.scrollTop < 24) return
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
void loadMoreImages()
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
}, [images.length, loadMoreImages, loadingImages, totalImages])
useEffect(() => {
const el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
const el = parentRef.current
if (!el) return
el.addEventListener('scroll', handleScroll, { passive: true })
return () => el.removeEventListener('scroll', handleScroll)
}, [handleScroll])
return (
<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 ? (
<GalleryLoadingState isSimilarResults={isSimilarResults} parsedSearch={parsedSearch} />
) : images.length === 0 && !loadingImages ? (
@@ -91,25 +98,25 @@ export function Gallery() {
parsedSearch={parsedSearch}
/>
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * cols;
const rowImages = images.slice(startIndex, startIndex + cols);
const startIndex = virtualRow.index * cols
const rowImages = images.slice(startIndex, startIndex + cols)
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
position: 'absolute',
top: virtualRow.start,
width: "100%",
width: '100%',
height: virtualRow.size,
display: "grid",
display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
boxSizing: 'border-box',
}}
>
{rowImages.map((image) => (
@@ -118,13 +125,13 @@ export function Gallery() {
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY, image })
}}
/>
))}
</div>
);
)
})}
</div>
)}
@@ -149,5 +156,5 @@ export function Gallery() {
container so it stays put while the grid scrolls. */}
<BulkActionBar />
</div>
);
)
}
+31 -23
View File
@@ -1,7 +1,7 @@
import { ImageRecord, useGalleryStore } from "../store";
import { ContextMenu, MenuItem, MenuLabel, MenuSeparator, SubMenu } from "./menu";
import { Tooltip } from "./Tooltip";
import { CloseIcon, StarIcon } from "./icons";
import { ImageRecord, useGalleryStore } from '../store'
import { ContextMenu, MenuItem, MenuLabel, MenuSeparator, SubMenu } from './menu'
import { Tooltip } from './Tooltip'
import { CloseIcon, StarIcon } from './icons'
/** Right-click menu for an image tile. Shared by the Gallery grid and the Timeline. */
export function ImageContextMenu({
@@ -10,27 +10,27 @@ export function ImageContextMenu({
image,
onClose,
}: {
x: number;
y: number;
image: ImageRecord;
onClose: () => void;
x: number
y: number
image: ImageRecord
onClose: () => void
}) {
const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const canFindSimilar = image.embedding_status === "ready";
const openImage = useGalleryStore((state) => state.openImage)
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails)
const findSimilar = useGalleryStore((state) => state.findSimilar)
const albums = useGalleryStore((state) => state.albums)
const addToAlbum = useGalleryStore((state) => state.addToAlbum)
const canFindSimilar = image.embedding_status === 'ready'
return (
<ContextMenu x={x} y={y} onClose={onClose}>
<MenuItem label="Open Preview" onSelect={() => openImage(image)} />
<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 })}
/>
<MenuItem
label={canFindSimilar ? "Find Similar" : "Embeddings not ready"}
label={canFindSimilar ? 'Find Similar' : 'Embeddings not ready'}
disabled={!canFindSimilar}
onSelect={() => findSimilar(image.id, image.folder_id)}
/>
@@ -52,23 +52,31 @@ export function ImageContextMenu({
<MenuLabel>Rating</MenuLabel>
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
const rating = index + 1
return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor>
<button
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>
</Tooltip>
);
)
})}
{image.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<button
className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors"
onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }}
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()
}}
>
<CloseIcon className="h-3 w-3" />
</button>
@@ -76,5 +84,5 @@ export function ImageContextMenu({
) : null}
</div>
</ContextMenu>
);
)
}
+6 -6
View File
@@ -6,23 +6,23 @@ export function InlineConfirm({
onConfirm,
onCancel,
}: {
onConfirm: () => void;
onCancel: () => void;
onConfirm: () => void
onCancel: () => void
}) {
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
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}
>
Confirm
</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}
>
Cancel
</button>
</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
@@ -10,41 +10,41 @@ export function InlineRename({
onRename,
onClose,
}: {
name: string;
onRename: (next: string) => Promise<void> | void;
onClose: () => void;
name: string
onRename: (next: string) => Promise<void> | void
onClose: () => void
}) {
const [value, setValue] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(name)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const commit = async () => {
const trimmed = value.trim();
const trimmed = value.trim()
if (trimmed && trimmed !== name) {
await onRename(trimmed);
await onRename(trimmed)
}
onClose()
}
onClose();
};
return (
<input
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}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void commit();
if (event.key === 'Enter') {
event.preventDefault()
void commit()
}
if (event.key === "Escape") onClose();
if (event.key === 'Escape') onClose()
}}
onBlur={() => void commit()}
onClick={(event) => event.stopPropagation()}
/>
);
)
}
+70 -62
View File
@@ -1,57 +1,59 @@
import { useCallback, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { LightboxDetailsPanel } from "./lightbox/LightboxDetailsPanel";
import { LightboxNavButton } from "./lightbox/LightboxNavButton";
import { LightboxViewport } from "./lightbox/LightboxViewport";
import { SlideshowView } from "./lightbox/SlideshowView";
import { useLightboxMediaDetails } from "./lightbox/useLightboxMediaDetails";
import { useLightboxNavigation } from "./lightbox/useLightboxNavigation";
import { useRegionSelection } from "./lightbox/useRegionSelection";
import { useSlideshow } from "./lightbox/useSlideshow";
import { ViewTransform } from "./lightbox/types";
import { IDENTITY_VIEW } from "./lightbox/viewTransform";
import { useCallback, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from '../store'
import { LightboxDetailsPanel } from './lightbox/LightboxDetailsPanel'
import { LightboxNavButton } from './lightbox/LightboxNavButton'
import { LightboxViewport } from './lightbox/LightboxViewport'
import { SlideshowView } from './lightbox/SlideshowView'
import { useLightboxMediaDetails } from './lightbox/useLightboxMediaDetails'
import { useLightboxNavigation } from './lightbox/useLightboxNavigation'
import { useRegionSelection } from './lightbox/useRegionSelection'
import { useSlideshow } from './lightbox/useSlideshow'
import { ViewTransform } from './lightbox/types'
import { IDENTITY_VIEW } from './lightbox/viewTransform'
export function Lightbox() {
const selectedImage = useGalleryStore((state) => state.selectedImage);
const closeImage = useGalleryStore((state) => state.closeImage);
const images = useGalleryStore((state) => state.images);
const openImage = useGalleryStore((state) => state.openImage);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const getImageTags = useGalleryStore((state) => state.getImageTags);
const addUserTag = useGalleryStore((state) => state.addUserTag);
const removeTag = useGalleryStore((state) => state.removeTag);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const getImageExif = useGalleryStore((state) => state.getImageExif);
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages);
const loadedCount = useGalleryStore((state) => state.loadedCount);
const totalImages = useGalleryStore((state) => state.totalImages);
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds);
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder);
const slideshowTransition = useGalleryStore((state) => state.slideshowTransition);
const selectedImage = useGalleryStore((state) => state.selectedImage)
const closeImage = useGalleryStore((state) => state.closeImage)
const images = useGalleryStore((state) => state.images)
const openImage = useGalleryStore((state) => state.openImage)
const findSimilar = useGalleryStore((state) => state.findSimilar)
const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion)
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails)
const getImageTags = useGalleryStore((state) => state.getImageTags)
const addUserTag = useGalleryStore((state) => state.addUserTag)
const removeTag = useGalleryStore((state) => state.removeTag)
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus)
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus)
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage)
const albums = useGalleryStore((state) => state.albums)
const addToAlbum = useGalleryStore((state) => state.addToAlbum)
const createAlbum = useGalleryStore((state) => state.createAlbum)
const getImageExif = useGalleryStore((state) => state.getImageExif)
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages)
const loadedCount = useGalleryStore((state) => state.loadedCount)
const totalImages = useGalleryStore((state) => state.totalImages)
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds)
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder)
const slideshowTransition = useGalleryStore((state) => state.slideshowTransition)
const lightboxRootRef = useRef<HTMLDivElement>(null);
const imageViewportRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW);
const lightboxRootRef = useRef<HTMLDivElement>(null)
const imageViewportRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW)
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
const canFindSimilar = selectedImage?.embedding_status === "ready";
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image";
const taggerReady = taggerModelStatus?.ready ?? false;
const taggerStatusKnown = taggerModelStatus !== null;
const currentIndex = selectedImage
? images.findIndex((image) => image.id === selectedImage.id)
: -1
const canFindSimilar = selectedImage?.embedding_status === 'ready'
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === 'image'
const taggerReady = taggerModelStatus?.ready ?? false
const taggerStatusKnown = taggerModelStatus !== null
const taggerButtonTooltip = !taggerStatusKnown
? "Checking AI tagger model..."
? 'Checking AI tagger model...'
: taggerReady
? "Queue AI tagging for this image"
: "AI tagger model not installed";
? 'Queue AI tagging for this image'
: 'AI tagger model not installed'
const region = useRegionSelection({
selectedImage,
@@ -61,7 +63,7 @@ export function Lightbox() {
view,
setView,
findSimilarByRegion,
});
})
const slideshow = useSlideshow({
rootRef: lightboxRootRef,
@@ -77,13 +79,13 @@ export function Lightbox() {
loadMoreImages,
exitRegionMode: region.exitRegionMode,
setView,
});
})
const resetForSelectedImage = useCallback(() => {
setView(IDENTITY_VIEW);
region.exitRegionMode();
region.setRegionSearching(false);
}, [region.exitRegionMode, region.setRegionSearching]);
setView(IDENTITY_VIEW)
region.exitRegionMode()
region.setRegionSearching(false)
}, [region.exitRegionMode, region.setRegionSearching])
const details = useLightboxMediaDetails({
selectedImage,
@@ -92,7 +94,7 @@ export function Lightbox() {
getImageExif,
loadTaggerModelStatus,
onSelectedImageReset: resetForSelectedImage,
});
})
const { goPrev, goNext } = useLightboxNavigation({
selectedImage,
@@ -109,15 +111,15 @@ export function Lightbox() {
openImage,
setView,
clampPan: region.clampPan,
});
})
const toggleRegionMode = useCallback(() => {
if (region.regionSelectMode) {
region.exitRegionMode();
region.exitRegionMode()
} else {
region.setRegionSelectMode(true);
region.setRegionSelectMode(true)
}
}, [region.exitRegionMode, region.regionSelectMode, region.setRegionSelectMode]);
}, [region.exitRegionMode, region.regionSelectMode, region.setRegionSelectMode])
return (
<AnimatePresence>
@@ -126,13 +128,19 @@ export function Lightbox() {
ref={lightboxRootRef}
key="lightbox"
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 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={slideshow.active ? slideshow.showControls : region.regionSelectMode ? undefined : closeImage}
onClick={
slideshow.active
? slideshow.showControls
: region.regionSelectMode
? undefined
: closeImage
}
>
{slideshow.active ? (
<SlideshowView
@@ -223,5 +231,5 @@ export function Lightbox() {
</motion.div>
) : null}
</AnimatePresence>
);
)
}
+7 -5
View File
@@ -6,14 +6,14 @@
//
// Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point —
// 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({
className,
dotClassName,
}: {
className?: string;
dotClassName?: string;
className?: string
dotClassName?: string
}) {
return (
<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(300)" />
</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>
);
)
}
+55 -49
View File
@@ -1,58 +1,58 @@
import { useEffect, useState } from "react";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
import { CloseIcon } from "./icons";
import { AiWorkspaceSettingsSection } from "./settings/AiWorkspaceSettingsSection";
import { GeneralSettingsSection } from "./settings/GeneralSettingsSection";
import { MediaSettingsSection } from "./settings/MediaSettingsSection";
import { StorageSettingsSection } from "./settings/StorageSettingsSection";
import { UpdatesSettingsSection } from "./settings/UpdatesSettingsSection";
import { SETTINGS_SECTIONS, SettingsSection } from "./settings/shared";
import { useEffect, useState } from 'react'
import { useGalleryStore } from '../store'
import { Tooltip } from './Tooltip'
import { CloseIcon } from './icons'
import { AiWorkspaceSettingsSection } from './settings/AiWorkspaceSettingsSection'
import { GeneralSettingsSection } from './settings/GeneralSettingsSection'
import { MediaSettingsSection } from './settings/MediaSettingsSection'
import { StorageSettingsSection } from './settings/StorageSettingsSection'
import { UpdatesSettingsSection } from './settings/UpdatesSettingsSection'
import { SETTINGS_SECTIONS, SettingsSection } from './settings/shared'
function ActiveSettingsSection({ section }: { section: SettingsSection }) {
switch (section) {
case "workspace":
return <AiWorkspaceSettingsSection />;
case "media":
return <MediaSettingsSection />;
case "updates":
return <UpdatesSettingsSection />;
case "storage":
return <StorageSettingsSection />;
case "general":
case 'workspace':
return <AiWorkspaceSettingsSection />
case 'media':
return <MediaSettingsSection />
case 'updates':
return <UpdatesSettingsSection />
case 'storage':
return <StorageSettingsSection />
case 'general':
default:
return <GeneralSettingsSection />;
return <GeneralSettingsSection />
}
}
export function SettingsModal() {
const [activeSection, setActiveSection] = useState<SettingsSection>("general");
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope);
const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration);
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel);
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold);
const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize);
const settingsOpen = useGalleryStore((state) => state.settingsOpen)
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen)
const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope)
const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds)
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus)
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration)
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel)
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold)
const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize)
useEffect(() => {
if (!settingsOpen) return;
void loadTaggerModelStatus();
void loadTaggerModel();
void loadTaggerAcceleration();
void loadTaggerThreshold();
void loadTaggerBatchSize();
void loadTaggingQueueScope();
void loadTaggingQueueFolderIds();
if (!settingsOpen) return
void loadTaggerModelStatus()
void loadTaggerModel()
void loadTaggerAcceleration()
void loadTaggerThreshold()
void loadTaggerBatchSize()
void loadTaggingQueueScope()
void loadTaggingQueueFolderIds()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
if (event.key === 'Escape') setSettingsOpen(false)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [
settingsOpen,
loadTaggerModelStatus,
@@ -63,14 +63,18 @@ export function SettingsModal() {
loadTaggingQueueScope,
loadTaggingQueueFolderIds,
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 (
<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
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()}
@@ -84,7 +88,9 @@ export function SettingsModal() {
<button
key={section.id}
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)}
>
@@ -95,7 +101,7 @@ export function SettingsModal() {
</div>
</aside>
<div className="absolute right-4 top-4 z-10">
<div className="absolute top-4 right-4 z-10">
<Tooltip label="Close settings" anchorToCursor>
<button
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>
</div>
</div>
);
)
}
+25 -25
View File
@@ -1,54 +1,54 @@
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
import { PlusIcon } from "./icons";
import { AlbumSection } from "./sidebar/AlbumSection";
import { LibrarySection } from "./sidebar/LibrarySection";
import { NavItem } from "./sidebar/NavItem";
import { useGalleryStore } from '../store'
import { Tooltip } from './Tooltip'
import { PlusIcon } from './icons'
import { AlbumSection } from './sidebar/AlbumSection'
import { LibrarySection } from './sidebar/LibrarySection'
import { NavItem } from './sidebar/NavItem'
export function Sidebar() {
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const selectFolder = useGalleryStore((state) => state.selectFolder);
const activeView = useGalleryStore((state) => state.activeView);
const setView = useGalleryStore((state) => state.setView);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const selectFolder = useGalleryStore((state) => state.selectFolder)
const activeView = useGalleryStore((state) => state.activeView)
const setView = useGalleryStore((state) => state.setView)
return (
<aside className="w-52 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06] lg:w-60">
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0">
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span>
<aside className="flex w-52 shrink-0 flex-col border-r border-white/[0.06] bg-gray-950 lg:w-60">
<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 tracking-wide text-white/80">Phokus</span>
<Tooltip label="Add Media Folder" anchorToCursor>
<button
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>
</Tooltip>
</div>
<div className="px-2 pt-2 pb-1 space-y-px">
<div className="space-y-px px-2 pt-2 pb-1">
<NavItem
label="All Media"
active={activeView === "gallery" && selectedFolderId === null}
active={activeView === 'gallery' && selectedFolderId === 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"
/>
<NavItem
label="Explore"
active={activeView === "explore"}
onClick={() => setView("explore")}
active={activeView === 'explore'}
onClick={() => setView('explore')}
iconPath="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
/>
<NavItem
label="Timeline"
active={activeView === "timeline"}
onClick={() => setView("timeline")}
active={activeView === '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"
/>
<NavItem
label="Duplicates"
active={activeView === "duplicates"}
onClick={() => setView("duplicates")}
active={activeView === '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"
/>
</div>
@@ -56,5 +56,5 @@ export function Sidebar() {
<LibrarySection />
<AlbumSection />
</aside>
);
)
}
+92 -99
View File
@@ -1,70 +1,69 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
import { ImageTile } from "./gallery/ImageTile";
import { ImageContextMenu } from "./ImageContextMenu";
import { ScrubberYearBlock } from "./timeline/ScrubberYearBlock";
import { TimelineEmptyState, TimelineLoadingState } from "./timeline/TimelineEmptyState";
import { buildScrubberYears, buildTimelineRows, groupImages } from "./timeline/timelineModel";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ImageRecord, tileSizeForZoom, useGalleryStore } from '../store'
import { ImageTile } from './gallery/ImageTile'
import { ImageContextMenu } from './ImageContextMenu'
import { ScrubberYearBlock } from './timeline/ScrubberYearBlock'
import { TimelineEmptyState, TimelineLoadingState } from './timeline/TimelineEmptyState'
import { buildScrubberYears, buildTimelineRows, groupImages } from './timeline/timelineModel'
const GAP = 6;
const HEADER_HEIGHT = 52;
const SCRUBBER_WIDTH = 48;
const GAP = 6
const HEADER_HEIGHT = 52
const SCRUBBER_WIDTH = 48
export function Timeline() {
const images = useGalleryStore((s) => s.images);
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
const openImage = useGalleryStore((s) => s.openImage);
const totalImages = useGalleryStore((s) => s.totalImages);
const loadingImages = useGalleryStore((s) => s.loadingImages);
const imageLoadError = useGalleryStore((s) => s.imageLoadError);
const zoomPreset = useGalleryStore((s) => s.zoomPreset);
const images = useGalleryStore((s) => s.images)
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages)
const openImage = useGalleryStore((s) => s.openImage)
const totalImages = useGalleryStore((s) => s.totalImages)
const loadingImages = useGalleryStore((s) => s.loadingImages)
const imageLoadError = useGalleryStore((s) => s.imageLoadError)
const zoomPreset = useGalleryStore((s) => s.zoomPreset)
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const parentRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const [activeGroupIndex, setActiveGroupIndex] = useState(0)
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
image: ImageRecord;
} | null>(null);
x: number
y: number
image: ImageRecord
} | null>(null)
// parentRef is the scroll container. Its clientWidth already excludes the
// scrubber because they are flex siblings, so no further adjustment is needed.
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const el = parentRef.current
if (!el) return
setContainerWidth(el.clientWidth)
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
setContainerWidth(entries[0].contentRect.width)
})
ro.observe(el)
return () => ro.disconnect()
}, [])
const tileSize = tileSizeForZoom(zoomPreset);
const groups = useMemo(() => groupImages(images), [images]);
const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]);
const tileSize = tileSizeForZoom(zoomPreset)
const groups = useMemo(() => groupImages(images), [images])
const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups])
// 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
// reflects whatever range is currently loaded.
const showScrubber = groups.length > 1;
const showScrubber = groups.length > 1
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
[containerWidth, tileSize]
)
const { rows, rowToGroupIndex, groupFirstRow } = useMemo(
() => buildTimelineRows(groups, cols),
[groups, cols],
);
[groups, cols]
)
const estimateSize = useCallback(
(index: number): number =>
rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
[rows, tileSize],
);
(index: number): number => (rows[index]?.type === 'header' ? HEADER_HEIGHT : tileSize + GAP),
[rows, tileSize]
)
const virtualizer = useVirtualizer({
count: rows.length,
@@ -72,107 +71,101 @@ export function Timeline() {
estimateSize,
overscan: 6,
paddingStart: GAP,
});
})
// Refs so the scroll handler can read the latest mappings without re-binding.
const rowToGroupIndexRef = useRef(rowToGroupIndex);
rowToGroupIndexRef.current = rowToGroupIndex;
const groupFirstRowRef = useRef(groupFirstRow);
groupFirstRowRef.current = groupFirstRow;
const rowToGroupIndexRef = useRef(rowToGroupIndex)
rowToGroupIndexRef.current = rowToGroupIndex
const groupFirstRowRef = useRef(groupFirstRow)
groupFirstRowRef.current = groupFirstRow
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
virtualizer.measure()
}, [cols, virtualizer])
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
const el = parentRef.current
if (!el) return
// Active month = the group owning the first row still visible at the top.
const scrollTop = el.scrollTop;
const items = virtualizer.getVirtualItems();
let activeRow = items.length > 0 ? items[0].index : 0;
const scrollTop = el.scrollTop
const items = virtualizer.getVirtualItems()
let activeRow = items.length > 0 ? items[0].index : 0
for (const item of items) {
if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
activeRow = item.index;
break;
activeRow = item.index
break
}
}
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0)
if (scrollTop < 24) return;
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (scrollTop < 24) return
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
void loadMoreImages()
}
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages])
useEffect(() => {
const el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
const el = parentRef.current
if (!el) return
el.addEventListener('scroll', handleScroll, { passive: true })
return () => el.removeEventListener('scroll', handleScroll)
}, [handleScroll])
const scrollToGroup = useCallback(
(groupIndex: number) => {
const row = groupFirstRowRef.current[groupIndex] ?? 0;
virtualizer.scrollToIndex(row, { align: "start" });
const row = groupFirstRowRef.current[groupIndex] ?? 0
virtualizer.scrollToIndex(row, { align: 'start' })
},
[virtualizer],
);
[virtualizer]
)
return (
// Outer flex-row: fills remaining height in <main>'s flex-col, then
// 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 */}
<div
ref={parentRef}
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0"
>
<div ref={parentRef} className="relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
{images.length === 0 && loadingImages ? (
<TimelineLoadingState />
) : images.length === 0 ? (
<TimelineEmptyState imageLoadError={imageLoadError} />
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const row = rows[virtualItem.index];
if (!row) return null;
const row = rows[virtualItem.index]
if (!row) return null
return (
<div
key={virtualItem.key}
style={{
position: "absolute",
position: 'absolute',
top: virtualItem.start,
width: "100%",
width: '100%',
height: virtualItem.size,
}}
>
{row.type === "header" ? (
<div
className="flex items-center gap-3 px-4"
style={{ height: HEADER_HEIGHT }}
>
<span className="text-sm font-semibold text-white/80 shrink-0">
{row.type === 'header' ? (
<div className="flex items-center gap-3 px-4" style={{ height: HEADER_HEIGHT }}>
<span className="shrink-0 text-sm font-semibold text-white/80">
{row.group.label}
</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}
</span>
<div className="flex-1 h-px bg-white/[0.06]" />
<div className="h-px flex-1 bg-white/[0.06]" />
</div>
) : (
<div
style={{
display: "grid",
display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
boxSizing: 'border-box',
}}
>
{row.images.map((image) => (
@@ -181,22 +174,22 @@ export function Timeline() {
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY, image })
}}
/>
))}
</div>
)}
</div>
);
)
})}
</div>
)}
{images.length > 0 && loadingImages ? (
<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>
) : null}
</div>
@@ -204,7 +197,7 @@ export function Timeline() {
{/* Scrubber — flex sibling so it stays visible while the left panel scrolls */}
{showScrubber ? (
<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 }}
>
{scrubberYears.map((yearEntry) => (
@@ -227,5 +220,5 @@ export function Timeline() {
/>
) : null}
</div>
);
)
}
+79 -54
View File
@@ -1,15 +1,15 @@
import { useState, useEffect, useRef } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore, AppTheme } from "../store";
import { ContextMenu, MenuItem, MenuLabel } from "./menu";
import { PhokusMark } from "./PhokusMark";
import { Tooltip } from "./Tooltip";
import { useState, useEffect, useRef } from 'react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useGalleryStore, AppTheme } from '../store'
import { ContextMenu, MenuItem, MenuLabel } from './menu'
import { PhokusMark } from './PhokusMark'
import { Tooltip } from './Tooltip'
const THEME_OPTIONS: { value: AppTheme; label: string }[] = [
{ value: "phokus", label: "Phokus" },
{ value: "subtle-light", label: "Subtle Light" },
{ value: "conventional-dark", label: "Conventional Dark" },
];
{ value: 'phokus', label: 'Phokus' },
{ value: 'subtle-light', label: 'Subtle Light' },
{ value: 'conventional-dark', label: 'Conventional Dark' },
]
// SVG icons for window controls
function MinimizeIcon() {
@@ -17,7 +17,7 @@ function MinimizeIcon() {
<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" />
</svg>
);
)
}
function MaximizeIcon() {
@@ -25,16 +25,25 @@ function MaximizeIcon() {
<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" />
</svg>
);
)
}
function RestoreIcon() {
return (
<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="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>
);
)
}
function CloseIcon() {
@@ -42,52 +51,52 @@ function CloseIcon() {
<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" />
</svg>
);
)
}
export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const theme = useGalleryStore((state) => state.theme);
const setTheme = useGalleryStore((state) => state.setTheme);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const installUpdate = useGalleryStore((state) => state.installUpdate);
const appWindow = getCurrentWindow();
const [isMaximized, setIsMaximized] = useState(false)
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen)
const theme = useGalleryStore((state) => state.theme)
const setTheme = useGalleryStore((state) => state.setTheme)
const updateStatus = useGalleryStore((state) => state.updateStatus)
const updateVersion = useGalleryStore((state) => state.updateVersion)
const installUpdate = useGalleryStore((state) => state.installUpdate)
const appWindow = getCurrentWindow()
// Right-clicking the settings cog opens a quick theme switcher, anchored under
// the cog so it never overflows the right edge of the window.
const settingsBtnRef = useRef<HTMLButtonElement>(null);
const [themeMenu, setThemeMenu] = useState<{ x: number; y: number } | null>(null);
const settingsBtnRef = useRef<HTMLButtonElement>(null)
const [themeMenu, setThemeMenu] = useState<{ x: number; y: number } | null>(null)
const handleSettingsContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const rect = settingsBtnRef.current?.getBoundingClientRect();
if (!rect) return;
setThemeMenu({ x: rect.right, y: rect.bottom + 4 });
};
e.preventDefault()
const rect = settingsBtnRef.current?.getBoundingClientRect()
if (!rect) return
setThemeMenu({ x: rect.right, y: rect.bottom + 4 })
}
useEffect(() => {
// Get initial maximized state
appWindow.isMaximized().then(setIsMaximized);
appWindow.isMaximized().then(setIsMaximized)
// Listen for resize events to update maximized state
const unlisten = appWindow.onResized(() => {
appWindow.isMaximized().then(setIsMaximized);
});
appWindow.isMaximized().then(setIsMaximized)
})
return () => {
unlisten.then((fn) => fn());
};
}, []);
unlisten.then((fn) => fn())
}
}, [])
const handleMinimize = () => appWindow.minimize();
const handleMaximize = () => appWindow.toggleMaximize();
const handleClose = () => appWindow.close();
const handleMinimize = () => appWindow.minimize()
const handleMaximize = () => appWindow.toggleMaximize()
const handleClose = () => appWindow.close()
// An update is waiting for the user to act. Covers the "clicked Later" case too,
// since dismissing the toast doesn't change updateStatus.
const updatePending = updateStatus === "available";
const updatePending = updateStatus === 'available'
return (
// data-tauri-drag-region is the recommended Tauri approach for drag regions.
@@ -95,31 +104,31 @@ export function TitleBar() {
<div
data-tauri-drag-region
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
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 ? (
<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. */}
<Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
<button
onClick={() => void installUpdate()}
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" />
</button>
</Tooltip>
</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" />
</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>
{/* Spacer — draggable region fills here */}
@@ -127,8 +136,8 @@ export function TitleBar() {
{/* Window control buttons — right side, non-draggable */}
<div
className="flex items-stretch h-full"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className="flex h-full items-stretch"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Tooltip label="Settings (right-click to switch theme)" anchorToCursor>
<button
@@ -139,8 +148,18 @@ export function TitleBar() {
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">
<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 strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<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
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</Tooltip>
@@ -156,7 +175,7 @@ export function TitleBar() {
{/* Maximize / Restore */}
<button
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"
>
{isMaximized ? <RestoreIcon /> : <MaximizeIcon />}
@@ -174,7 +193,13 @@ export function TitleBar() {
{/* Quick theme switcher — opened by right-clicking the settings cog. */}
{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>
{THEME_OPTIONS.map((opt) => (
<MenuItem
@@ -187,5 +212,5 @@ export function TitleBar() {
</ContextMenu>
)}
</div>
);
)
}
+8 -8
View File
@@ -1,12 +1,12 @@
import { SortControl } from "./toolbar/SortControl";
import { ToolbarFilters } from "./toolbar/ToolbarFilters";
import { ToolbarSearch } from "./toolbar/ToolbarSearch";
import { ToolbarTitle } from "./toolbar/ToolbarTitle";
import { useToolbarSearch } from "./toolbar/useToolbarSearch";
import { ZoomControl } from "./toolbar/ZoomControl";
import { SortControl } from './toolbar/SortControl'
import { ToolbarFilters } from './toolbar/ToolbarFilters'
import { ToolbarSearch } from './toolbar/ToolbarSearch'
import { ToolbarTitle } from './toolbar/ToolbarTitle'
import { useToolbarSearch } from './toolbar/useToolbarSearch'
import { ZoomControl } from './toolbar/ZoomControl'
export function Toolbar() {
const searchState = useToolbarSearch();
const searchState = useToolbarSearch()
return (
<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>
<ToolbarFilters />
</div>
);
)
}
+86 -79
View File
@@ -1,22 +1,23 @@
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { motion } from "framer-motion";
import { MouseEvent, ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
type TooltipSide = "top" | "bottom" | "left" | "right";
type TooltipAlign = "center" | "start" | "end";
type TooltipSide = 'top' | 'bottom' | 'left' | 'right'
type TooltipAlign = 'center' | 'start' | 'end'
// Horizontal alignment only applies to top/bottom; left/right center vertically.
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 === "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 horizontal = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return `${vertical} ${horizontal}`;
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'
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'
return `${vertical} ${horizontal}`
}
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";
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
'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`
/**
* 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({
label,
delay = 400,
side = "bottom",
align = "center",
side = 'bottom',
align = 'center',
block = false,
anchorToCursor = false,
followCursor = false,
disabled = false,
className = "",
className = '',
children,
}: {
label: ReactNode;
label: ReactNode
/** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */
delay?: number;
side?: TooltipSide;
delay?: number
side?: TooltipSide
/** Horizontal alignment for top/bottom tooltips. */
align?: TooltipAlign;
align?: TooltipAlign
/** 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. */
anchorToCursor?: boolean;
anchorToCursor?: boolean
/** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean;
followCursor?: boolean
/** Render the wrapper but suppress tooltip display. */
disabled?: boolean;
disabled?: boolean
/** Extra classes for the wrapper (e.g. layout). */
className?: string;
children: ReactNode;
className?: string
children: ReactNode
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [mounted, setMounted] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false)
const [coords, setCoords] = useState({ x: 0, y: 0 })
const [mounted, setMounted] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const frame = useRef<number | undefined>(undefined)
const tooltipRef = useRef<HTMLSpanElement>(null)
// 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
// clamped so it never runs off the right/left.
const place = (clientX: number, clientY: number) => {
const tip = tooltipRef.current;
const tipWidth = tip?.offsetWidth ?? 0;
const tipHeight = tip?.offsetHeight ?? 24;
const gap = 16;
let top = clientY + gap;
const tip = tooltipRef.current
const tipWidth = tip?.offsetWidth ?? 0
const tipHeight = tip?.offsetHeight ?? 24
const gap = 16
let top = clientY + gap
if (top + tipHeight > window.innerHeight - 4) {
top = clientY - gap - tipHeight;
top = clientY - gap - tipHeight
}
let left = clientX + 14
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth
if (left < 4) left = 4
setCoords({ x: left, y: top })
}
let left = clientX + 14;
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth;
if (left < 4) left = 4;
setCoords({ x: left, y: top });
};
const clear = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = undefined;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = undefined;
};
const show = (event: MouseEvent<HTMLElement>) => {
if (disabled) return;
clear();
if (anchorToCursor || followCursor) place(event.clientX, event.clientY);
if (delay <= 0) {
setVisible(true);
return;
if (timer.current) clearTimeout(timer.current)
timer.current = undefined
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = undefined
}
const show = (event: MouseEvent<HTMLElement>) => {
if (disabled) return
clear()
if (anchorToCursor || followCursor) place(event.clientX, event.clientY)
if (delay <= 0) {
setVisible(true)
return
}
timer.current = setTimeout(() => setVisible(true), delay)
}
timer.current = setTimeout(() => setVisible(true), delay);
};
const hide = () => {
clear();
setVisible(false);
};
clear()
setVisible(false)
}
const move = (event: MouseEvent<HTMLElement>) => {
if (!followCursor) return;
const { clientX, clientY } = event;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
if (!followCursor) return
const { clientX, clientY } = event
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = requestAnimationFrame(() => {
frame.current = undefined;
place(clientX, clientY);
});
};
frame.current = undefined
place(clientX, clientY)
})
}
useEffect(() => {
setMounted(true);
return clear;
}, []);
setMounted(true)
return clear
}, [])
useEffect(() => {
if (disabled) hide();
}, [disabled]);
if (disabled) hide()
}, [disabled])
const Wrapper = block ? "div" : "span";
const Wrapper = block ? 'div' : 'span'
const cursorTooltip = (
<motion.span
ref={tooltipRef}
@@ -130,18 +131,22 @@ export function Tooltip({
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: followCursor ? { type: "spring", stiffness: 700, damping: 45, mass: 0.35 } : { duration: 0 },
top: followCursor ? { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } : { duration: 0 },
left: followCursor
? { 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 },
}}
>
{label}
</motion.span>
);
)
return (
<Wrapper
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
className={`${block ? 'relative block w-full' : 'relative inline-flex'} ${className}`}
onMouseEnter={show}
onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide}
@@ -149,16 +154,18 @@ export function Tooltip({
>
{children}
{disabled ? null : anchorToCursor || followCursor ? (
mounted ? createPortal(cursorTooltip, document.body) : null
mounted ? (
createPortal(cursorTooltip, document.body)
) : null
) : (
<span
role="tooltip"
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}
</span>
)}
</Wrapper>
);
)
}
+21 -15
View File
@@ -1,17 +1,19 @@
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from '../store'
export function UpdateToast() {
const updateStatus = useGalleryStore((s) => s.updateStatus);
const updateVersion = useGalleryStore((s) => s.updateVersion);
const updateProgress = useGalleryStore((s) => s.updateProgress);
const updateDismissed = useGalleryStore((s) => s.updateDismissed);
const installUpdate = useGalleryStore((s) => s.installUpdate);
const dismissUpdate = useGalleryStore((s) => s.dismissUpdate);
const updateStatus = useGalleryStore((s) => s.updateStatus)
const updateVersion = useGalleryStore((s) => s.updateVersion)
const updateProgress = useGalleryStore((s) => s.updateProgress)
const updateDismissed = useGalleryStore((s) => s.updateDismissed)
const installUpdate = useGalleryStore((s) => s.installUpdate)
const dismissUpdate = useGalleryStore((s) => s.dismissUpdate)
const visible =
!updateDismissed &&
(updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing");
(updateStatus === 'available' ||
updateStatus === 'downloading' ||
updateStatus === 'installing')
return (
<AnimatePresence>
@@ -21,9 +23,9 @@ export function UpdateToast() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
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="mt-1 text-xs text-gray-500">
@@ -47,14 +49,18 @@ export function UpdateToast() {
) : (
<>
<p className="text-sm font-medium text-white">
{updateStatus === "installing" ? "Installing update..." : "Downloading update..."}
{updateStatus === 'installing' ? 'Installing update...' : 'Downloading update...'}
</p>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-white/10">
<div
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>
<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>
) : null}
</AnimatePresence>
);
)
}
+11 -11
View File
@@ -1,13 +1,13 @@
import { VideoControls } from "./videoPlayer/VideoControls";
import { useVideoPlayer } from "./videoPlayer/useVideoPlayer";
import { VideoControls } from './videoPlayer/VideoControls'
import { useVideoPlayer } from './videoPlayer/useVideoPlayer'
export function VideoPlayer({ src }: { src: string }) {
const player = useVideoPlayer(src);
const player = useVideoPlayer(src)
return (
<div
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}
onClick={(event) => event.stopPropagation()}
>
@@ -18,17 +18,17 @@ export function VideoPlayer({ src }: { src: string }) {
onClick={player.togglePlay}
onDoubleClick={player.toggleFullscreen}
onPlay={() => {
player.setPlaying(true);
player.showControls();
player.setPlaying(true)
player.showControls()
}}
onPause={() => {
player.setPlaying(false);
player.setControlsVisible(true);
player.setPlaying(false)
player.setControlsVisible(true)
}}
onTimeUpdate={(event) => player.setCurrentTime(event.currentTarget.currentTime)}
onLoadedMetadata={(event) => {
player.setDuration(event.currentTarget.duration);
player.readBuffered();
player.setDuration(event.currentTarget.duration)
player.readBuffered()
}}
onDurationChange={(event) => player.setDuration(event.currentTarget.duration)}
onProgress={player.readBuffered}
@@ -60,5 +60,5 @@ export function VideoPlayer({ src }: { src: string }) {
trackRef={player.trackRef}
/>
</div>
);
)
}
+60 -50
View File
@@ -1,14 +1,14 @@
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store";
import { getChangelogForVersion, type ChangelogSection } from "../changelog";
import { Tooltip } from "./Tooltip";
import { ChevronRightIcon, CloseIcon } from "./icons";
import { useEffect, useState, type ReactNode } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { invoke } from '@tauri-apps/api/core'
import { useGalleryStore } from '../store'
import { getChangelogForVersion, type ChangelogSection } from '../changelog'
import { Tooltip } from './Tooltip'
import { ChevronRightIcon, CloseIcon } from './icons'
// 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.
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
// 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,
// so neutral surfaces must use the gray scale and trust the remap.
const SECTION_STYLES: Record<string, { label: string; dot: string }> = {
Added: { label: "text-emerald-300", dot: "bg-emerald-400/80" },
Changed: { label: "text-sky-300", dot: "bg-sky-300/80" },
Fixed: { label: "text-amber-300", dot: "bg-amber-400/80" },
Removed: { label: "text-rose-300", dot: "bg-rose-400/80" },
Security: { label: "text-violet-300", dot: "bg-violet-400/80" },
};
Added: { label: 'text-emerald-300', dot: 'bg-emerald-400/80' },
Changed: { label: 'text-sky-300', dot: 'bg-sky-300/80' },
Fixed: { label: 'text-amber-300', dot: 'bg-amber-400/80' },
Removed: { label: 'text-rose-300', dot: 'bg-rose-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
// `code`. Anything else passes through as plain text.
function renderInline(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const regex = /\*\*(.+?)\*\*|`([^`]+)`/g;
let lastIndex = 0;
let key = 0;
let match: RegExpExecArray | null;
const nodes: ReactNode[] = []
const regex = /\*\*(.+?)\*\*|`([^`]+)`/g
let lastIndex = 0
let key = 0
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
nodes.push(text.slice(lastIndex, match.index))
}
if (match[1] !== undefined) {
nodes.push(
<strong key={key++} className="font-semibold text-white">
{match[1]}
</strong>,
);
</strong>
)
} else if (match[2] !== undefined) {
nodes.push(
<code key={key++} className="rounded bg-white/10 px-1 py-0.5 text-[12px] text-gray-200">
{match[2]}
</code>,
);
</code>
)
}
lastIndex = regex.lastIndex;
lastIndex = regex.lastIndex
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
nodes.push(text.slice(lastIndex))
}
return nodes;
return nodes
}
function Section({ section }: { section: ChangelogSection }) {
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE;
const [open, setOpen] = useState(true);
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE
const [open, setOpen] = useState(true)
return (
<section>
@@ -70,15 +70,20 @@ function Section({ section }: { section: ChangelogSection }) {
className="flex w-full items-center gap-2 text-left"
aria-expanded={open}
>
<ChevronRightIcon className={`h-3 w-3 shrink-0 text-gray-600 transition-transform ${open ? "rotate-90" : ""}`} strokeWidth={2.5} />
<span className={`text-[11px] font-semibold uppercase tracking-[0.1em] ${style.label}`}>{section.title}</span>
<ChevronRightIcon
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>
</button>
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
@@ -104,24 +109,24 @@ function Section({ section }: { section: ChangelogSection }) {
) : null}
</AnimatePresence>
</section>
);
)
}
export function WhatsNewModal() {
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen);
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew);
const appVersion = useGalleryStore((s) => s.appVersion);
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew)
const appVersion = useGalleryStore((s) => s.appVersion)
const entry = getChangelogForVersion(appVersion);
const entry = getChangelogForVersion(appVersion)
useEffect(() => {
if (!whatsNewOpen) return;
if (!whatsNewOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeWhatsNew();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [whatsNewOpen, closeWhatsNew]);
if (event.key === 'Escape') closeWhatsNew()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [whatsNewOpen, closeWhatsNew])
return (
<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>
<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">
Phokus v{entry?.version ?? appVersion ?? "—"}
Phokus v{entry?.version ?? appVersion ?? ''}
</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>
<Tooltip label="Close" anchorToCursor>
<button
@@ -165,7 +174,8 @@ export function WhatsNewModal() {
entry.sections.map((section) => <Section key={section.title} section={section} />)
) : (
<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>
)}
</div>
@@ -174,7 +184,7 @@ export function WhatsNewModal() {
<Tooltip label={CHANGELOG_URL} side="top" align="start">
<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"
>
Full changelog
@@ -191,5 +201,5 @@ export function WhatsNewModal() {
</motion.div>
) : null}
</AnimatePresence>
);
)
}
+9 -9
View File
@@ -1,15 +1,15 @@
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from '../store'
// 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).
export function WhatsNewToast() {
const whatsNewToast = useGalleryStore((s) => s.whatsNewToast);
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen);
const openWhatsNew = useGalleryStore((s) => s.openWhatsNew);
const dismissWhatsNewToast = useGalleryStore((s) => s.dismissWhatsNewToast);
const whatsNewToast = useGalleryStore((s) => s.whatsNewToast)
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
const openWhatsNew = useGalleryStore((s) => s.openWhatsNew)
const dismissWhatsNewToast = useGalleryStore((s) => s.dismissWhatsNewToast)
const visible = whatsNewToast !== null && !whatsNewOpen;
const visible = whatsNewToast !== null && !whatsNewOpen
return (
<AnimatePresence>
@@ -19,7 +19,7 @@ export function WhatsNewToast() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
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="mt-1 text-xs text-gray-500">See what's changed in this version.</p>
@@ -40,5 +40,5 @@ export function WhatsNewToast() {
</motion.div>
) : null}
</AnimatePresence>
);
)
}
@@ -1,66 +1,67 @@
import { Tooltip } from "../Tooltip";
import { CloseIcon } from "../icons";
import type { BackgroundTask } from "./types";
import { Tooltip } from '../Tooltip'
import { CloseIcon } from '../icons'
import type { BackgroundTask } from './types'
export function FailureActions({
onLocate,
onRetry,
task,
}: {
onLocate: (folderId: number) => void;
onRetry: (task: BackgroundTask) => void;
task: BackgroundTask;
onLocate: (folderId: number) => void
onRetry: (task: BackgroundTask) => void
task: BackgroundTask
}) {
if (task.pendingMediaWork !== 0 || (!task.hasFailedEmbeddings && !task.hasFailedTagging)) return null;
if (task.pendingMediaWork !== 0 || (!task.hasFailedEmbeddings && !task.hasFailedTagging))
return null
return (
<div className="flex shrink-0 items-center gap-1.5">
{task.hasFailedTagging ? (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.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) => {
event.stopPropagation();
onLocate(task.id);
event.stopPropagation()
onLocate(task.id)
}}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
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) => {
event.stopPropagation();
onRetry(task);
event.stopPropagation()
onRetry(task)
}}
>
Retry
</button>
</div>
);
)
}
export function DismissTaskButton({
onDismiss,
size = "normal",
size = 'normal',
task,
}: {
onDismiss: (task: BackgroundTask) => void;
size?: "normal" | "small";
task: BackgroundTask;
onDismiss: (task: BackgroundTask) => void
size?: 'normal' | 'small'
task: BackgroundTask
}) {
if (task.id < 0) return null;
if (task.id < 0) return null
return (
<Tooltip label="Dismiss" anchorToCursor>
<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) => {
event.stopPropagation();
onDismiss(task);
event.stopPropagation()
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>
</Tooltip>
);
)
}
@@ -1,9 +1,9 @@
import type { WorkerKey } from "../../store";
import { ChevronDownIcon } from "../icons";
import { DismissTaskButton, FailureActions } from "./BackgroundTaskActions";
import { TaskProgressBar } from "./TaskProgressBar";
import { TaskStagePill } from "./TaskStagePill";
import type { BackgroundTask } from "./types";
import type { WorkerKey } from '../../store'
import { ChevronDownIcon } from '../icons'
import { DismissTaskButton, FailureActions } from './BackgroundTaskActions'
import { TaskProgressBar } from './TaskProgressBar'
import { TaskStagePill } from './TaskStagePill'
import type { BackgroundTask } from './types'
export function BackgroundTaskSummary({
expanded,
@@ -19,34 +19,36 @@ export function BackgroundTaskSummary({
progress,
taskCount,
}: {
expanded: boolean;
extraCount: number;
hasFailed: boolean;
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean;
onDismiss: (task: BackgroundTask) => void;
onLocate: (folderId: number) => void;
onRetry: (task: BackgroundTask) => void;
onToggleExpanded: () => void;
onToggleWorker: (folderId: number, worker: WorkerKey) => void;
primary: BackgroundTask;
progress: number | null;
taskCount: number;
expanded: boolean
extraCount: number
hasFailed: boolean
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean
onDismiss: (task: BackgroundTask) => void
onLocate: (folderId: number) => void
onRetry: (task: BackgroundTask) => void
onToggleExpanded: () => void
onToggleWorker: (folderId: number, worker: WorkerKey) => void
primary: BackgroundTask
progress: number | null
taskCount: number
}) {
return (
<div
className={`group flex items-center gap-3 px-5 h-11 cursor-pointer select-none transition-colors ${
expanded ? "bg-white/[0.03]" : "hover:bg-white/[0.02]"
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]'
}`}
onClick={onToggleExpanded}
>
<div className="relative shrink-0">
<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={`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 animate-ping rounded-full opacity-60 ${hasFailed ? 'bg-amber-400' : 'bg-blue-400'}`}
/>
</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) => (
<TaskStagePill
key={stage.label}
@@ -61,7 +63,7 @@ export function BackgroundTaskSummary({
<TaskProgressBar failed={hasFailed} progress={progress} widthClass="w-24" />
{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}
</span>
) : null}
@@ -69,10 +71,12 @@ export function BackgroundTaskSummary({
<FailureActions onLocate={onLocate} onRetry={onRetry} task={primary} />
{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}
<DismissTaskButton onDismiss={onDismiss} task={primary} />
</div>
);
)
}
@@ -1,10 +1,10 @@
import type { WorkerKey } from "../../store";
import { DismissTaskButton, FailureActions } from "./BackgroundTaskActions";
import { FailedWorkerItemRow } from "./FailedWorkerItemRow";
import { taskHasTerminalFailure, taskProgress } from "./taskModel";
import { TaskProgressBar } from "./TaskProgressBar";
import { TaskStagePill } from "./TaskStagePill";
import type { BackgroundTask, FailedWorkerItem } from "./types";
import type { WorkerKey } from '../../store'
import { DismissTaskButton, FailureActions } from './BackgroundTaskActions'
import { FailedWorkerItemRow } from './FailedWorkerItemRow'
import { taskHasTerminalFailure, taskProgress } from './taskModel'
import { TaskProgressBar } from './TaskProgressBar'
import { TaskStagePill } from './TaskStagePill'
import type { BackgroundTask, FailedWorkerItem } from './types'
export function ExpandedTaskPanel({
failedEmbeddingItems,
@@ -16,27 +16,27 @@ export function ExpandedTaskPanel({
onToggleWorker,
tasks,
}: {
failedEmbeddingItems: Record<number, FailedWorkerItem[]>;
failedTaggingItems: Record<number, FailedWorkerItem[]>;
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean;
onDismiss: (task: BackgroundTask) => void;
onLocate: (folderId: number) => void;
onRetry: (task: BackgroundTask) => void;
onToggleWorker: (folderId: number, worker: WorkerKey) => void;
tasks: BackgroundTask[];
failedEmbeddingItems: Record<number, FailedWorkerItem[]>
failedTaggingItems: Record<number, FailedWorkerItem[]>
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean
onDismiss: (task: BackgroundTask) => void
onLocate: (folderId: number) => void
onRetry: (task: BackgroundTask) => void
onToggleWorker: (folderId: number, worker: WorkerKey) => void
tasks: BackgroundTask[]
}) {
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) => {
const progress = taskProgress(task);
const failed = taskHasTerminalFailure(task);
const progress = taskProgress(task)
const failed = taskHasTerminalFailure(task)
return (
<div key={task.id}>
<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) => (
<TaskStagePill
key={stage.label}
@@ -57,28 +57,28 @@ export function ExpandedTaskPanel({
</div>
{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}
</p>
) : null}
{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) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
) : null}
{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) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
) : null}
</div>
);
)
})}
</div>
);
)
}
@@ -1,28 +1,36 @@
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { Tooltip } from "../Tooltip";
import { WarningIcon } from "../icons";
import type { FailedWorkerItem } from "./types";
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { Tooltip } from '../Tooltip'
import { WarningIcon } from '../icons'
import type { FailedWorkerItem } from './types'
export function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
return (
<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">
<p className="truncate text-[10px] font-medium text-amber-400/80 light-theme:text-amber-700">{item.filename}</p>
{item.error ? (
<p className="truncate text-[9px] text-gray-600">{item.error}</p>
) : null}
<p className="light-theme:text-amber-700 truncate text-[10px] font-medium text-amber-400/80">
{item.filename}
</p>
{item.error ? <p className="truncate text-[9px] text-gray-600">{item.error}</p> : null}
</div>
<Tooltip label="Reveal in Explorer" anchorToCursor>
<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)}
>
<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
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
/>
</svg>
</button>
</Tooltip>
</div>
);
)
}
@@ -1,24 +1,24 @@
export function TaskProgressBar({
failed,
progress,
widthClass = "w-20",
widthClass = 'w-20',
}: {
failed: boolean;
progress: number | null;
widthClass?: string;
failed: boolean
progress: number | null
widthClass?: string
}) {
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
className={`h-full rounded-full transition-all duration-500 ${
failed
? "bg-amber-400/60"
? 'bg-amber-400/60'
: progress === null
? "bg-blue-500/40 animate-pulse w-full"
: "bg-blue-500"
? 'w-full animate-pulse bg-blue-500/40'
: 'bg-blue-500'
}`}
style={progress !== null ? { width: `${progress}%` } : undefined}
/>
</div>
);
)
}
@@ -1,8 +1,8 @@
import type { WorkerKey } from "../../store";
import { Tooltip } from "../Tooltip";
import { PlayIcon } from "../icons";
import type { TaskStage } from "./types";
import { WORKER_FOR_STAGE } from "./types";
import type { WorkerKey } from '../../store'
import { Tooltip } from '../Tooltip'
import { PlayIcon } from '../icons'
import type { TaskStage } from './types'
import { WORKER_FOR_STAGE } from './types'
export function TaskStagePill({
folderId,
@@ -11,25 +11,25 @@ export function TaskStagePill({
onToggleWorker,
stage,
}: {
folderId: number;
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean;
mutedWhenPaused?: boolean;
onToggleWorker: (folderId: number, worker: WorkerKey) => void;
stage: TaskStage;
folderId: number
isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean
mutedWhenPaused?: boolean
onToggleWorker: (folderId: number, worker: WorkerKey) => void
stage: TaskStage
}) {
const workerKey = WORKER_FOR_STAGE[stage.label];
const isPaused = workerKey ? isWorkerPaused(folderId, workerKey) : false;
const workerKey = WORKER_FOR_STAGE[stage.label]
const isPaused = workerKey ? isWorkerPaused(folderId, workerKey) : false
return (
<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
? "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
? "bg-white/4 text-gray-600"
? 'bg-white/4 text-gray-600'
: mutedWhenPaused
? "bg-white/5 text-gray-500"
: "bg-white/5 text-gray-400"
? 'bg-white/5 text-gray-500'
: 'bg-white/5 text-gray-400'
}`}
>
{isPaused && mutedWhenPaused ? (
@@ -38,16 +38,22 @@ export function TaskStagePill({
</svg>
) : null}
<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}
</span>
{workerKey ? (
<Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor>
<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={
mutedWhenPaused
? 'ml-0.5 text-gray-600 transition-colors hover:text-white'
: 'ml-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:text-white'
}
onClick={(event) => {
event.stopPropagation();
onToggleWorker(folderId, workerKey);
event.stopPropagation()
onToggleWorker(folderId, workerKey)
}}
>
{isPaused ? (
@@ -61,5 +67,5 @@ export function TaskStagePill({
</Tooltip>
) : null}
</span>
);
)
}
+127 -72
View File
@@ -1,5 +1,11 @@
import type { DuplicateScanProgress, Folder, FolderJobProgress, IndexProgress, WorkerKey } from "../../store";
import type { BackgroundTask, TaskStage } from "./types";
import type {
DuplicateScanProgress,
Folder,
FolderJobProgress,
IndexProgress,
WorkerKey,
} from '../../store'
import type { BackgroundTask, TaskStage } from './types'
export function buildFolderTasks({
dismissed,
@@ -8,110 +14,143 @@ export function buildFolderTasks({
mediaJobProgress,
workerPaused,
}: {
dismissed: Record<number, string>;
folders: Folder[];
indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>;
workerPaused: Record<number, Record<WorkerKey, boolean>>;
dismissed: Record<number, string>
folders: Folder[]
indexingProgress: Record<number, IndexProgress>
mediaJobProgress: Record<number, FolderJobProgress>
workerPaused: Record<number, Record<WorkerKey, boolean>>
}): BackgroundTask[] {
return folders
.map((folder): BackgroundTask | null => {
const index = indexingProgress[folder.id];
const jobs = mediaJobProgress[folder.id];
const index = indexingProgress[folder.id]
const jobs = mediaJobProgress[folder.id]
const thumbnailPending = jobs?.thumbnail_pending ?? 0;
const metadataPending = jobs?.metadata_pending ?? 0;
const embeddingPending = jobs?.embedding_pending ?? 0;
const embeddingReady = jobs?.embedding_ready ?? 0;
const embeddingFailed = jobs?.embedding_failed ?? 0;
const taggingPending = jobs?.tagging_pending ?? 0;
const taggingReady = jobs?.tagging_ready ?? 0;
const taggingFailed = jobs?.tagging_failed ?? 0;
const captionPending = jobs?.caption_pending ?? 0;
const captionReady = jobs?.caption_ready ?? 0;
const captionFailed = jobs?.caption_failed ?? 0;
const thumbnailPending = jobs?.thumbnail_pending ?? 0
const metadataPending = jobs?.metadata_pending ?? 0
const embeddingPending = jobs?.embedding_pending ?? 0
const embeddingReady = jobs?.embedding_ready ?? 0
const embeddingFailed = jobs?.embedding_failed ?? 0
const taggingPending = jobs?.tagging_pending ?? 0
const taggingReady = jobs?.tagging_ready ?? 0
const taggingFailed = jobs?.tagging_failed ?? 0
const captionPending = jobs?.caption_pending ?? 0
const captionReady = jobs?.caption_ready ?? 0
const captionFailed = jobs?.caption_failed ?? 0
const paused = workerPaused[folder.id];
const paused = workerPaused[folder.id]
const isActive =
(!!index && !index.done) ||
(thumbnailPending > 0 && !(paused?.thumbnail ?? false)) ||
(metadataPending > 0 && !(paused?.metadata ?? false)) ||
(embeddingPending > 0 && !(paused?.embedding ?? false)) ||
(taggingPending > 0 && !(paused?.tagging ?? false)) ||
captionPending > 0;
captionPending > 0
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending;
const embeddingProcessed = embeddingReady + embeddingFailed;
const embeddingTotal = embeddingProcessed + embeddingPending;
const taggingProcessed = taggingReady + taggingFailed;
const taggingTotal = taggingProcessed + taggingPending;
const captionProcessed = captionReady + captionFailed;
const captionTotal = captionProcessed + captionPending;
const hasFailedEmbeddings = embeddingFailed > 0;
const hasFailedTagging = taggingFailed > 0;
const hasFailedCaptions = captionFailed > 0;
const pendingMediaWork =
thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending
const embeddingProcessed = embeddingReady + embeddingFailed
const embeddingTotal = embeddingProcessed + embeddingPending
const taggingProcessed = taggingReady + taggingFailed
const taggingTotal = taggingProcessed + taggingPending
const captionProcessed = captionReady + captionFailed
const captionTotal = captionProcessed + captionPending
const hasFailedEmbeddings = embeddingFailed > 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) {
stages.push({
label: "Scanning",
label: 'Scanning',
detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`,
progress: index.total > 0 ? (index.indexed / index.total) * 100 : 0,
failed: false,
});
})
}
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) {
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) {
stages.push({
label: "Embeddings",
label: 'Embeddings',
detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`,
progress: embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0,
failed: false,
});
})
}
if (taggingPending > 0) {
stages.push({
label: "Tags",
label: 'Tags',
detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`,
progress: taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0,
failed: false,
});
})
}
if (captionPending > 0) {
stages.push({
label: "Captions",
label: 'Captions',
detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`,
progress: captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0,
failed: false,
});
})
}
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) {
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) {
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 {
id: folder.id,
@@ -124,35 +163,42 @@ export function buildFolderTasks({
pendingMediaWork,
embeddingProcessed,
embeddingTotal,
currentFile: index && !index.done ? (index.current_file || null) : null,
currentFile: index && !index.done ? index.current_file || null : null,
snapshot,
};
}
})
.filter((task): task is BackgroundTask => task !== null)
.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 {
if (!duplicateScanning) return null;
export function buildDuplicateScanTask(
duplicateScanning: boolean,
duplicateScanProgress: DuplicateScanProgress | null
): BackgroundTask | null {
if (!duplicateScanning) return null
return {
id: -1,
name: "Duplicate Scan",
stages: [{
label: duplicateScanProgress?.phase === "checking"
? "Checking"
: duplicateScanProgress?.phase === "confirming"
? "Confirming"
: "Hashing",
name: 'Duplicate Scan',
stages: [
{
label:
duplicateScanProgress?.phase === 'checking'
? 'Checking'
: duplicateScanProgress?.phase === 'confirming'
? 'Confirming'
: 'Hashing',
detail: duplicateScanProgress
? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
: "Starting…",
progress: duplicateScanProgress && duplicateScanProgress.total > 0
? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ''}`
: 'Starting…',
progress:
duplicateScanProgress && duplicateScanProgress.total > 0
? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100
: null,
failed: false,
}],
},
],
isActive: true,
hasFailedEmbeddings: false,
hasFailedTagging: false,
@@ -161,18 +207,27 @@ export function buildDuplicateScanTask(duplicateScanning: boolean, duplicateScan
embeddingProcessed: 0,
embeddingTotal: 0,
currentFile: null,
snapshot: "",
};
snapshot: '',
}
}
export function taskProgress(task: BackgroundTask): number | null {
const embeddingStage = task.stages.find((stage) => stage.label === "Embeddings");
const taggingStage = task.stages.find((stage) => stage.label === "Tags");
const scanningStage = task.stages.find((stage) => stage.label === "Scanning");
const duplicateStage = task.id === -1 ? task.stages[0] : null;
return embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null;
const embeddingStage = task.stages.find((stage) => stage.label === 'Embeddings')
const taggingStage = task.stages.find((stage) => stage.label === 'Tags')
const scanningStage = task.stages.find((stage) => stage.label === 'Scanning')
const duplicateStage = task.id === -1 ? task.stages[0] : null
return (
embeddingStage?.progress ??
taggingStage?.progress ??
scanningStage?.progress ??
duplicateStage?.progress ??
null
)
}
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> = {
Thumbnails: "thumbnail",
Metadata: "metadata",
Embeddings: "embedding",
Tags: "tagging",
};
Thumbnails: 'thumbnail',
Metadata: 'metadata',
Embeddings: 'embedding',
Tags: 'tagging',
}
export interface TaskStage {
label: string;
detail: string;
progress: number | null;
failed: boolean;
label: string
detail: string
progress: number | null
failed: boolean
}
export interface BackgroundTask {
id: number;
name: string;
stages: TaskStage[];
isActive: boolean;
hasFailedEmbeddings: boolean;
hasFailedTagging: boolean;
hasFailedCaptions: boolean;
pendingMediaWork: number;
embeddingProcessed: number;
embeddingTotal: number;
currentFile: string | null;
snapshot: string;
id: number
name: string
stages: TaskStage[]
isActive: boolean
hasFailedEmbeddings: boolean
hasFailedTagging: boolean
hasFailedCaptions: boolean
pendingMediaWork: number
embeddingProcessed: number
embeddingTotal: number
currentFile: string | null
snapshot: string
}
export interface FailedWorkerItem {
image_id: number;
filename: string;
path: string;
error: string | null;
image_id: number
filename: string
path: string
error: string | null
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { AlbumPicker } from "../AlbumPicker";
import { AlbumPicker } from '../AlbumPicker'
interface BulkAlbumPopoverProps {
onPick: (albumId: number) => Promise<void>;
onPick: (albumId: number) => Promise<void>
}
export function BulkAlbumPopover({ onPick }: BulkAlbumPopoverProps) {
@@ -12,5 +12,5 @@ export function BulkAlbumPopover({ onPick }: BulkAlbumPopoverProps) {
>
<AlbumPicker onPick={(albumId) => void onPick(albumId)} />
</div>
);
)
}
+15 -10
View File
@@ -1,13 +1,18 @@
import { WarningIcon } from "../icons";
import { WarningIcon } from '../icons'
interface BulkDeleteConfirmProps {
deleting: boolean;
selectedCount: number;
onCancel: () => void;
onDelete: () => Promise<void>;
deleting: boolean
selectedCount: number
onCancel: () => void
onDelete: () => Promise<void>
}
export function BulkDeleteConfirm({ deleting, selectedCount, onCancel, onDelete }: BulkDeleteConfirmProps) {
export function BulkDeleteConfirm({
deleting,
selectedCount,
onCancel,
onDelete,
}: BulkDeleteConfirmProps) {
return (
<div
data-bulk-popover
@@ -18,8 +23,8 @@ export function BulkDeleteConfirm({ deleting, selectedCount, onCancel, onDelete
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. This removes
the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
Permanently delete {selectedCount} file{selectedCount === 1 ? '' : 's'} from your computer.
This removes the actual file{selectedCount === 1 ? '' : 's'} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
@@ -33,9 +38,9 @@ export function BulkDeleteConfirm({ deleting, selectedCount, onCancel, onDelete
onClick={() => void onDelete()}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} from disk`}
{deleting ? 'Deleting…' : `Delete ${selectedCount} from disk`}
</button>
</div>
</div>
);
)
}
+11 -11
View File
@@ -1,16 +1,16 @@
import { Tooltip } from "../Tooltip";
import { StarIcon } from "../icons";
import { Tooltip } from '../Tooltip'
import { StarIcon } from '../icons'
interface BulkRatingPopoverProps {
onClose: () => void;
onSetRating: (rating: number) => Promise<void>;
onClose: () => void
onSetRating: (rating: number) => Promise<void>
}
export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverProps) {
const setRating = async (rating: number) => {
await onSetRating(rating);
onClose();
};
await onSetRating(rating)
onClose()
}
return (
<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"
>
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
const rating = index + 1
return (
<Tooltip key={rating} label={`Set ${rating} star${rating === 1 ? "" : "s"}`}>
<Tooltip key={rating} label={`Set ${rating} star${rating === 1 ? '' : 's'}`}>
<button
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
onClick={() => void setRating(rating)}
@@ -28,7 +28,7 @@ export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverPro
<StarIcon className="h-4 w-4" />
</button>
</Tooltip>
);
)
})}
<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"
@@ -37,5 +37,5 @@ export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverPro
Clear
</button>
</div>
);
)
}
+15 -10
View File
@@ -1,10 +1,10 @@
import { Tooltip } from "../Tooltip";
import { Tooltip } from '../Tooltip'
interface BulkSelectionSummaryProps {
loadedCount: number;
selectedCount: number;
totalImages: number;
onSelectAll: () => void;
loadedCount: number
selectedCount: number
totalImages: number
onSelectAll: () => void
}
export function BulkSelectionSummary({
@@ -13,18 +13,23 @@ export function BulkSelectionSummary({
totalImages,
onSelectAll,
}: BulkSelectionSummaryProps) {
const showSelectAll = loadedCount < totalImages || loadedCount > selectedCount;
const showSelectAll = loadedCount < totalImages || loadedCount > selectedCount
return (
<div className="flex items-center gap-2 px-1.5">
<span className="text-xs font-medium text-white">{selectedCount} selected</span>
{showSelectAll ? (
<Tooltip label={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}>
<button className="text-[11px] text-gray-500 transition-colors hover:text-gray-300" onClick={onSelectAll}>
Select all{loadedCount < totalImages ? " loaded" : ""}
<Tooltip
label={loadedCount < totalImages ? 'Selects loaded items only' : 'Select all loaded'}
>
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={onSelectAll}
>
Select all{loadedCount < totalImages ? ' loaded' : ''}
</button>
</Tooltip>
) : null}
</div>
);
)
}
+13 -9
View File
@@ -1,26 +1,26 @@
import { useBulkTagEditor } from "./useBulkTagEditor";
import { Tooltip } from "../Tooltip";
import { CloseIcon } from "../icons";
import { useBulkTagEditor } from './useBulkTagEditor'
import { Tooltip } from '../Tooltip'
import { CloseIcon } from '../icons'
// Presentational tag-editing fields shared by the popover and modal surfaces.
export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
const { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag } =
useBulkTagEditor();
useBulkTagEditor()
return (
<div className="space-y-2">
<form
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
void addTag(input);
event.preventDefault()
void addTag(input)
}}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
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"
placeholder={`Add tag to ${selectedCount} item${selectedCount === 1 ? "" : "s"}`}
placeholder={`Add tag to ${selectedCount} item${selectedCount === 1 ? '' : 's'}`}
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={pending}
@@ -37,7 +37,11 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => (
<Tooltip key={suggestion.tag} label={`${suggestion.count.toLocaleString()} tagged`} anchorToCursor>
<Tooltip
key={suggestion.tag}
label={`${suggestion.count.toLocaleString()} tagged`}
anchorToCursor
>
<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"
onClick={() => void addTag(suggestion.tag)}
@@ -70,5 +74,5 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
</div>
) : null}
</div>
);
)
}
+6 -9
View File
@@ -1,6 +1,6 @@
import { BulkTagFields } from "./BulkTagFields";
import { Tooltip } from "../Tooltip";
import { CloseIcon } from "../icons";
import { BulkTagFields } from './BulkTagFields'
import { Tooltip } from '../Tooltip'
import { CloseIcon } from '../icons'
// Inline popover surface for bulk tagging — the default editing surface.
// 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()}
>
<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>
<button
className="text-gray-600 transition-colors hover:text-white"
onClick={onClose}
>
<button className="text-gray-600 transition-colors hover:text-white" onClick={onClose}>
<CloseIcon className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
<BulkTagFields autoFocus />
</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 { invoke } from "@tauri-apps/api/core";
import { ExploreTagEntry, useGalleryStore } from "../../store";
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { ExploreTagEntry, useGalleryStore } from '../../store'
// Shared logic for the bulk tag editor, consumed by both the inline popover and
// the modal surface so they stay behaviorally identical.
export function useBulkTagEditor() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const bulkAddTags = useGalleryStore((state) => state.bulkAddTags);
const bulkRemoveTag = useGalleryStore((state) => state.bulkRemoveTag);
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const bulkAddTags = useGalleryStore((state) => state.bulkAddTags)
const bulkRemoveTag = useGalleryStore((state) => state.bulkRemoveTag)
const [input, setInput] = useState("");
const [suggestions, setSuggestions] = useState<ExploreTagEntry[]>([]);
const [appliedTags, setAppliedTags] = useState<string[]>([]);
const [pending, setPending] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const requestRef = useRef(0);
const [input, setInput] = useState('')
const [suggestions, setSuggestions] = useState<ExploreTagEntry[]>([])
const [appliedTags, setAppliedTags] = useState<string[]>([])
const [pending, setPending] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const requestRef = useRef(0)
useEffect(() => {
const query = input.trim();
const query = input.trim()
if (!query) {
requestRef.current += 1;
setSuggestions([]);
return;
requestRef.current += 1
setSuggestions([])
return
}
if (debounceRef.current) clearTimeout(debounceRef.current);
const requestId = ++requestRef.current;
if (debounceRef.current) clearTimeout(debounceRef.current)
const requestId = ++requestRef.current
debounceRef.current = setTimeout(async () => {
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 },
});
if (requestId !== requestRef.current) return;
setSuggestions(results);
})
if (requestId !== requestRef.current) return
setSuggestions(results)
} catch {
if (requestId !== requestRef.current) return;
setSuggestions([]);
if (requestId !== requestRef.current) return
setSuggestions([])
}
}, 120);
}, 120)
return () => {
requestRef.current += 1;
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [input, selectedFolderId]);
requestRef.current += 1
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [input, selectedFolderId])
const addTag = useCallback(
async (raw: string) => {
const tag = raw.trim();
if (!tag || pending) return;
setPending(true);
const tag = raw.trim()
if (!tag || pending) return
setPending(true)
try {
await bulkAddTags([tag]);
setAppliedTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
setInput("");
setSuggestions([]);
await bulkAddTags([tag])
setAppliedTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]))
setInput('')
setSuggestions([])
} finally {
setPending(false);
setPending(false)
}
},
[bulkAddTags, pending],
);
[bulkAddTags, pending]
)
const removeTag = useCallback(
async (tag: string) => {
await bulkRemoveTag(tag);
setAppliedTags((prev) => prev.filter((entry) => entry !== tag));
await bulkRemoveTag(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 (
<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" />
<span className="text-sm">{progressLabel ? `${progressLabel}` : "Preparing scan…"}</span>
<span className="text-sm">{progressLabel ? `${progressLabel}` : 'Preparing scan…'}</span>
</div>
);
)
}
export function DuplicateScanIntroState() {
return (
<div className="flex flex-1 items-center justify-center px-8">
<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">
<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
className="mx-auto mb-4 h-10 w-10 text-white/10"
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>
<p className="text-sm text-white/30">
Finds files with identical content regardless of filename or location.
Click <strong className="text-white/50">Scan for duplicates</strong> to begin.
Finds files with identical content regardless of filename or location. Click{' '}
<strong className="text-white/50">Scan for duplicates</strong> to begin.
</p>
<p className="mt-2 text-xs text-white/20">
Large libraries may take a minute files are hashed from disk.
</p>
</div>
</div>
);
)
}
export function DuplicateScanEmptyState() {
@@ -32,5 +41,5 @@ export function DuplicateScanEmptyState() {
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-white/25">No duplicate files found.</p>
</div>
);
)
}
@@ -1,13 +1,13 @@
import { DuplicateGroup, DuplicateScanProgress } from "../../store";
import { FolderScopeDropdown } from "../FolderScopeDropdown";
import { Tooltip } from "../Tooltip";
import { WarningIcon } from "../icons";
import { DuplicateGroup, DuplicateScanProgress } from '../../store'
import { FolderScopeDropdown } from '../FolderScopeDropdown'
import { Tooltip } from '../Tooltip'
import { WarningIcon } from '../icons'
import {
duplicateProgressLabel,
duplicateProgressPercent,
formatBytes,
formatRelativeTime,
} from "./format";
} from './format'
export function DuplicateFinderHeader({
confirmingDelete,
@@ -29,32 +29,35 @@ export function DuplicateFinderHeader({
selectedCount,
setConfirmingDelete,
}: {
confirmingDelete: boolean;
deleteResult: string | null;
deleting: boolean;
duplicateGroups: DuplicateGroup[];
duplicateLastScanned: number | null;
duplicateScanError: string | null;
duplicateScanning: boolean;
duplicateScanProgress: DuplicateScanProgress | null;
duplicateScanWarning: string | null;
hasResults: boolean;
hasScanned: boolean;
onClearSelection: () => void;
onConfirmDelete: () => void;
onDelete: () => void;
onScan: () => void;
onSelectKeepFirstAll: () => void;
selectedCount: number;
setConfirmingDelete: (value: boolean | ((current: boolean) => boolean)) => void;
confirmingDelete: boolean
deleteResult: string | null
deleting: boolean
duplicateGroups: DuplicateGroup[]
duplicateLastScanned: number | null
duplicateScanError: string | null
duplicateScanning: boolean
duplicateScanProgress: DuplicateScanProgress | null
duplicateScanWarning: string | null
hasResults: boolean
hasScanned: boolean
onClearSelection: () => void
onConfirmDelete: () => void
onDelete: () => void
onScan: () => void
onSelectKeepFirstAll: () => void
selectedCount: number
setConfirmingDelete: (value: boolean | ((current: boolean) => boolean)) => void
}) {
const totalWasted = duplicateGroups.reduce(
(sum, group) => sum + group.file_size * (group.images.length - 1),
0,
);
const totalDuplicateImages = duplicateGroups.reduce((sum, group) => sum + group.images.length - 1, 0);
const progressLabel = duplicateProgressLabel(duplicateScanProgress);
const progressPercent = duplicateProgressPercent(duplicateScanProgress);
0
)
const totalDuplicateImages = duplicateGroups.reduce(
(sum, group) => sum + group.images.length - 1,
0
)
const progressLabel = duplicateProgressLabel(duplicateScanProgress)
const progressPercent = duplicateProgressPercent(duplicateScanProgress)
return (
<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">
{duplicateScanning
? duplicateScanProgress
? `${progressLabel}${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
: "Starting scan…"
? `${progressLabel}${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ''}`
: 'Starting scan…'
: hasResults
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable`
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? '' : 's'} · ${formatBytes(totalWasted)} reclaimable`
: duplicateLastScanned !== null
? "No duplicates found"
: "Scan your library for identical files"}
? 'No duplicates found'
: 'Scan your library for identical files'}
</p>
{!duplicateScanning && duplicateLastScanned !== null ? (
<p className="mt-0.5 text-[10px] text-white/20">
@@ -81,9 +84,12 @@ export function DuplicateFinderHeader({
<div className="flex items-center gap-2">
<FolderScopeDropdown />
{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
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}
>
Select all duplicates
@@ -94,7 +100,7 @@ export function DuplicateFinderHeader({
<>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<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}
disabled={deleting}
>
@@ -106,19 +112,22 @@ export function DuplicateFinderHeader({
onClick={() => setConfirmingDelete((value) => !value)}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
{deleting
? 'Deleting…'
: `Delete ${selectedCount} file${selectedCount === 1 ? '' : 's'}`}
</button>
{confirmingDelete && !deleting ? (
<>
<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">
<WarningIcon className="h-3.5 w-3.5 shrink-0" />
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
Permanently delete {selectedCount} file{selectedCount === 1 ? '' : 's'} from
your computer. This removes the actual file{selectedCount === 1 ? '' : 's'}{' '}
from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
@@ -141,11 +150,11 @@ export function DuplicateFinderHeader({
</>
) : null}
<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}
disabled={duplicateScanning}
>
{duplicateScanning ? "Scanning…" : hasScanned ? "Rescan" : "Scan for duplicates"}
{duplicateScanning ? 'Scanning…' : hasScanned ? 'Rescan' : 'Scan for duplicates'}
</button>
</div>
</div>
@@ -165,9 +174,7 @@ export function DuplicateFinderHeader({
{duplicateScanWarning ? (
<p className="mt-2 text-[11px] text-amber-300/70">{duplicateScanWarning}</p>
) : null}
{deleteResult ? (
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
) : null}
{deleteResult ? <p className="mt-2 text-[11px] text-white/40">{deleteResult}</p> : null}
</div>
);
)
}
@@ -1,28 +1,28 @@
import { DuplicateGroup, useGalleryStore } from "../../store";
import { mediaSrc } from "../../lib/mediaSrc";
import { Tooltip } from "../Tooltip";
import { formatBytes } from "./format";
import { DuplicateGroup, useGalleryStore } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from '../Tooltip'
import { formatBytes } from './format'
export function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected);
const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates);
const groupSelectedCount = group.images.filter((image) => selectedIds.has(image.id)).length;
const noneSelected = groupSelectedCount === 0;
const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds)
const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected)
const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates)
const groupSelectedCount = group.images.filter((image) => selectedIds.has(image.id)).length
const noneSelected = groupSelectedCount === 0
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) {
if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id);
if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id)
}
selectAllDuplicates(toDelete)
}
selectAllDuplicates(toDelete);
};
const handleDeselectGroup = () => {
for (const image of group.images) {
if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id);
if (selectedIds.has(image.id)) toggleDuplicateSelected(image.id)
}
}
};
return (
<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">
{group.images.map((image) => {
const isSelected = selectedIds.has(image.id);
const src = mediaSrc(image.thumbnail_path);
const isSelected = selectedIds.has(image.id)
const src = mediaSrc(image.thumbnail_path)
return (
<Tooltip key={image.id} label={image.path} anchorToCursor>
<button
className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${
isSelected
? "border-red-400/50 ring-1 ring-red-400/30"
: "border-white/8 hover:border-white/20"
? 'border-red-400/50 ring-1 ring-red-400/30'
: 'border-white/8 hover:border-white/20'
}`}
style={{ width: 140, height: 105 }}
onClick={() => toggleDuplicateSelected(image.id)}
@@ -77,19 +77,31 @@ export function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
)}
{isSelected ? (
<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">
<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
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>
</div>
) : 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">
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p>
<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>
</div>
</button>
</Tooltip>
);
)
})}
</div>
</div>
);
)
}
+15 -15
View File
@@ -1,29 +1,29 @@
import { DuplicateScanProgress } from "../../store";
import { DuplicateScanProgress } from '../../store'
export function formatBytes(bytes: number): string {
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 >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
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 >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${bytes} B`
}
export function formatRelativeTime(unixSecs: number): string {
const diff = Math.floor(Date.now() / 1000) - unixSecs;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
const diff = Math.floor(Date.now() / 1000) - unixSecs
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
export function duplicateProgressLabel(progress: DuplicateScanProgress | null): string | null {
if (!progress) return null;
if (progress.phase === "checking") return "Checking file sizes";
if (progress.phase === "hashing") return "Hashing duplicate candidates";
return "Confirming exact matches";
if (!progress) return null
if (progress.phase === 'checking') return 'Checking file sizes'
if (progress.phase === 'hashing') return 'Hashing duplicate candidates'
return 'Confirming exact matches'
}
export function duplicateProgressPercent(progress: DuplicateScanProgress | null): number {
return progress && progress.total > 0
? Math.round((progress.processed / progress.total) * 100)
: 0;
: 0
}
+139 -96
View File
@@ -1,61 +1,65 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import type { VisualClusterEntry } from "../../store";
import { mediaSrc } from "../../lib/mediaSrc";
import { Tooltip } from "../Tooltip";
import { ACCENTS, GOLDEN_ANGLE, seeded } from "./layout";
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import type { VisualClusterEntry } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from '../Tooltip'
import { ACCENTS, GOLDEN_ANGLE, seeded } from './layout'
interface PlacedNode {
entry: VisualClusterEntry;
index: number;
x: number;
y: number;
w: number;
h: number;
zIndex: number;
accent: string;
driftX: number;
driftY: number;
driftDuration: number;
rotateSeed: number;
entry: VisualClusterEntry
index: number
x: number
y: number
w: number
h: number
zIndex: number
accent: string
driftX: number
driftY: number
driftDuration: number
rotateSeed: number
}
function buildCloud(entries: VisualClusterEntry[], containerW: number, containerH: number): PlacedNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return [];
function buildCloud(
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 cx = containerW / 2;
const cy = containerH / 2;
const n = entries.length;
const ASPECT = 0.72;
const PAD = 18;
const maxCount = Math.max(...entries.map((e) => e.count))
const cx = containerW / 2
const cy = containerH / 2
const n = entries.length
const ASPECT = 0.72
const PAD = 18
// 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.
const rawWidth = (count: number) => {
const ratio = Math.max(count / maxCount, 0.05);
return 92 + Math.pow(ratio, 0.65) * 158; // ~92250px before fit scaling
};
const ratio = Math.max(count / maxCount, 0.05)
return 92 + Math.pow(ratio, 0.65) * 158 // ~92250px before fit scaling
}
// Shrink every card uniformly when their padded footprint can't fit the
// canvas, so overlap resolution can actually pull them apart instead of
// settling into a pile. (0.6 leaves headroom for imperfect packing.)
const totalArea = entries.reduce((sum, e) => {
const w = rawWidth(e.count);
return sum + (w + PAD) * (w * ASPECT + PAD);
}, 0);
const usableArea = containerW * containerH * 0.6;
const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1;
const w = rawWidth(e.count)
return sum + (w + PAD) * (w * ASPECT + PAD)
}, 0)
const usableArea = containerW * containerH * 0.6
const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1
const spreadX = containerW * 0.44;
const spreadY = containerH * 0.4;
const spreadX = containerW * 0.44
const spreadY = containerH * 0.4
// 1. Seed positions on a phyllotaxis spiral, sized by count.
const nodes: PlacedNode[] = entries.map((entry, i) => {
const w = rawWidth(entry.count) * fit;
const h = w * ASPECT;
const radialRatio = Math.sqrt((i + 0.5) / n);
const angle = i * GOLDEN_ANGLE;
const w = rawWidth(entry.count) * fit
const h = w * ASPECT
const radialRatio = Math.sqrt((i + 0.5) / n)
const angle = i * GOLDEN_ANGLE
return {
entry,
@@ -72,63 +76,84 @@ function buildCloud(entries: VisualClusterEntry[], containerW: number, container
driftY: (seeded(i + 17) - 0.5) * 14,
driftDuration: 8 + seeded(i + 23) * 7,
rotateSeed: (seeded(i + 31) - 0.5) * 4,
};
});
}
})
// 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
// re-overlapping there.
const marginX = 14;
const marginY = 14;
const marginX = 14
const marginY = 14
for (let iter = 0; iter < 160; iter++) {
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++) {
const nb = nodes[b];
const dx = nb.x - na.x;
const dy = nb.y - na.y;
const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx);
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue;
const nb = nodes[b]
const dx = nb.x - na.x
const dy = nb.y - na.y
const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx)
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy)
if (overlapX <= 0 || overlapY <= 0) continue
// Push along the smaller overlap axis (ternary yields ±1 so coincident
// cards still separate rather than stalling at a zero push).
if (overlapX < overlapY) {
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
nb.x += push;
na.x -= push;
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1)
nb.x += push
na.x -= push
} else {
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1);
nb.y += push;
na.y -= push;
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1)
nb.y += push
na.y -= push
}
}
}
for (const node of nodes) {
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.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)
}
}
return nodes;
return nodes
}
function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) {
const src = mediaSrc(node.entry.thumbnail_path);
const { w, h, accent } = node;
function CloudCard({
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 = {
duration: node.driftDuration,
ease: "easeInOut" as const,
ease: 'easeInOut' as const,
delay: seeded(node.index + 41) * 1.6,
repeat: 1,
repeatType: "reverse" as const,
};
repeatType: 'reverse' as const,
}
return (
<Tooltip label={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`} followCursor>
<Tooltip
label={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? 'image' : 'images'}`}
followCursor
>
<motion.button
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"
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 }}
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
? {
@@ -146,10 +171,21 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
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 },
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) },
}
: { 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)}
@@ -173,21 +209,28 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
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] uppercase tracking-[0.18em] text-white/35">Cluster</p>
<p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
<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 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"
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>
);
)
}
// Separate component so its useLayoutEffect fires when the canvas is actually
@@ -197,34 +240,34 @@ export function ClusterCloud({
entries,
onOpen,
}: {
entries: VisualClusterEntry[];
onOpen: (imageIds: number[]) => void;
entries: VisualClusterEntry[]
onOpen: (imageIds: number[]) => void
}) {
const reducedMotion = useReducedMotion();
const canvasRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
const reducedMotion = useReducedMotion()
const canvasRef = useRef<HTMLDivElement>(null)
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 })
useLayoutEffect(() => {
const el = canvasRef.current;
if (!el) return;
const el = canvasRef.current
if (!el) return
const update = () => {
const r = el.getBoundingClientRect();
setCanvasSize({ w: r.width, h: r.height });
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const r = el.getBoundingClientRect()
setCanvasSize({ w: r.width, h: r.height })
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
const nodes = useMemo(
() => buildCloud(entries, canvasSize.w, canvasSize.h),
[entries, canvasSize.w, canvasSize.h],
);
[entries, canvasSize.w, canvasSize.h]
)
return (
<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) => (
<CloudCard
key={`${node.entry.representative_image_id}:${node.index}`}
@@ -234,5 +277,5 @@ export function ClusterCloud({
/>
))}
</div>
);
)
}
+10 -12
View File
@@ -1,22 +1,22 @@
import { motion } from "framer-motion";
import type { ExploreMode } from "../../store";
import { motion } from 'framer-motion'
import type { ExploreMode } from '../../store'
function Spinner() {
return (
<motion.div
className="explore-spinner h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
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 }) {
const title = mode === "visual" ? "Building visual clusters" : "Reading tag frequencies";
const title = mode === 'visual' ? 'Building visual clusters' : 'Reading tag frequencies'
const subtitle =
mode === "visual"
? "Grouping similar images into browsable clusters."
: "Collecting tags from the current library scope.";
mode === 'visual'
? 'Grouping similar images into browsable clusters.'
: 'Collecting tags from the current library scope.'
return (
<div className="flex flex-1 items-center justify-center px-8">
@@ -30,12 +30,10 @@ export function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) {
<motion.div
className="h-full w-16 rounded-full bg-white/25"
animate={{ x: [-72, 184] }}
transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut" }}
transition={{ duration: 1.4, repeat: Infinity, ease: 'easeInOut' }}
/>
</div>
</div>
</div>
);
)
}
+195 -171
View File
@@ -1,54 +1,59 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import type { ExploreTagEntry, RelatedTagEntry } from "../../store";
import { useGalleryStore } from "../../store";
import { Tooltip } from "../Tooltip";
import { ACCENTS, GOLDEN_ANGLE, LIGHT_ACCENTS, seeded } from "./layout";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import type { ExploreTagEntry, RelatedTagEntry } from '../../store'
import { useGalleryStore } from '../../store'
import { Tooltip } from '../Tooltip'
import { ACCENTS, GOLDEN_ANGLE, LIGHT_ACCENTS, seeded } from './layout'
interface AtlasNode {
entry: ExploreTagEntry;
index: number;
x: number;
y: number;
w: number;
h: number;
fontSize: number;
ratio: number;
accent: string;
driftX: number;
driftY: number;
entry: ExploreTagEntry
index: number
x: number
y: number
w: number
h: number
fontSize: number
ratio: number
accent: string
driftX: number
driftY: number
}
interface TagAnchor {
x: number;
y: number;
x: number
y: number
}
export const TAG_ATLAS_MAX_VISIBLE = 132;
const TAG_ATLAS_DENSE_THRESHOLD = 120;
export const TAG_ATLAS_MAX_VISIBLE = 132
const TAG_ATLAS_DENSE_THRESHOLD = 120
function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, containerH: number, isLight: boolean): AtlasNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return [];
function buildTagAtlas(
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 logMin = Math.min(...logs);
const logRange = Math.max(...logs) - logMin || 1;
const cx = containerW / 2;
const cy = containerH * 0.48;
const spreadX = containerW * 0.47;
const spreadY = containerH * 0.46;
const accents = isLight ? LIGHT_ACCENTS : ACCENTS;
const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1)))
const logMin = Math.min(...logs)
const logRange = Math.max(...logs) - logMin || 1
const cx = containerW / 2
const cy = containerH * 0.48
const spreadX = containerW * 0.47
const spreadY = containerH * 0.46
const accents = isLight ? LIGHT_ACCENTS : ACCENTS
const nodes = entries.map((entry, index) => {
const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange;
const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1;
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 textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26);
const w = Math.min(containerW * maxWidthRatio, textWidth);
const h = fontSize * 1.18 + 14;
const radialRatio = Math.sqrt((index + 0.5) / entries.length);
const angle = index * GOLDEN_ANGLE;
const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange
const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1
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 textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26)
const w = Math.min(containerW * maxWidthRatio, textWidth)
const h = fontSize * 1.18 + 14
const radialRatio = Math.sqrt((index + 0.5) / entries.length)
const angle = index * GOLDEN_ANGLE
return {
entry,
index,
@@ -61,41 +66,41 @@ function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, container
accent: accents[index % accents.length],
driftX: (seeded(index + 101) - 0.5) * 7,
driftY: (seeded(index + 113) - 0.5) * 6,
};
});
}
})
const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10;
const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7;
const margin = 28;
const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10
const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7
const margin = 28
for (let iter = 0; iter < 140; iter++) {
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++) {
const nb = nodes[b];
const dx = nb.x - na.x;
const dy = nb.y - na.y;
const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx);
const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue;
const nb = nodes[b]
const dx = nb.x - na.x
const dy = nb.y - na.y
const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx)
const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy)
if (overlapX <= 0 || overlapY <= 0) continue
if (overlapX < overlapY) {
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
nb.x += push;
na.x -= push;
const push = (overlapX / 2) * (dx >= 0 ? 1 : -1)
nb.x += push
na.x -= push
} else {
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1);
nb.y += push;
na.y -= push;
const push = (overlapY / 2) * (dy >= 0 ? 1 : -1)
nb.y += push
na.y -= push
}
}
}
for (const node of nodes) {
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.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)
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) {
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++) {
const weighted = nodes.reduce(
(acc, node) => {
const weight = 1 + Math.pow(node.ratio, 1.35) * 9;
const weight = 1 + Math.pow(node.ratio, 1.35) * 9
return {
x: acc.x + node.x * weight,
y: acc.y + node.y * weight,
weight: acc.weight + weight,
};
}
},
{ x: 0, y: 0, weight: 0 },
);
const offsetX = containerW * 0.48 - weighted.x / weighted.weight;
const offsetY = containerH * 0.44 - weighted.y / weighted.weight;
if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break;
{ x: 0, y: 0, weight: 0 }
)
const offsetX = containerW * 0.48 - weighted.x / weighted.weight
const offsetY = containerH * 0.44 - weighted.y / weighted.weight
if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break
for (const node of nodes) {
node.x = Math.min(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);
node.x = Math.min(
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({
@@ -129,145 +140,155 @@ export function TagAtlas({
onSearch,
loadRelatedTags,
}: {
entries: ExploreTagEntry[];
onSearch: (tag: string) => void;
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
entries: ExploreTagEntry[]
onSearch: (tag: string) => void
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>
}) {
const theme = useGalleryStore((state) => state.theme);
const isLight = theme === "subtle-light";
const reducedMotion = useReducedMotion();
const canvasRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
const [activeTag, setActiveTag] = useState<string | null>(null);
const [relatedTags, setRelatedTags] = useState<RelatedTagEntry[]>([]);
const [anchors, setAnchors] = useState<Record<string, TagAnchor>>({});
const theme = useGalleryStore((state) => state.theme)
const isLight = theme === 'subtle-light'
const reducedMotion = useReducedMotion()
const canvasRef = useRef<HTMLDivElement>(null)
const buttonRefs = useRef(new Map<string, HTMLButtonElement>())
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 })
const [activeTag, setActiveTag] = useState<string | null>(null)
const [relatedTags, setRelatedTags] = useState<RelatedTagEntry[]>([])
const [anchors, setAnchors] = useState<Record<string, TagAnchor>>({})
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(() => {
const el = canvasRef.current;
if (!el) return;
const el = canvasRef.current
if (!el) return
const update = () => {
const r = el.getBoundingClientRect();
setCanvasSize({ w: r.width, h: r.height });
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const r = el.getBoundingClientRect()
setCanvasSize({ w: r.width, h: r.height })
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
useEffect(() => {
if (!activeTag) {
setRelatedTags([]);
return;
setRelatedTags([])
return
}
let cancelled = false;
let cancelled = false
void loadRelatedTags(activeTag).then((entries) => {
if (!cancelled) setRelatedTags(entries);
});
if (!cancelled) setRelatedTags(entries)
})
return () => {
cancelled = true;
};
}, [activeTag, loadRelatedTags]);
cancelled = true
}
}, [activeTag, loadRelatedTags])
const nodes = useMemo(
() => buildTagAtlas(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 activeNode = activeTag ? nodeByTag.get(activeTag) : undefined;
const minOpacity = isLight ? 0.62 : 0.42;
[visibleEntries, canvasSize.w, canvasSize.h, isLight]
)
const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes])
const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined
const minOpacity = isLight ? 0.62 : 0.42
const visibleConnections = useMemo(
() =>
activeNode
? relatedTags
.map((related) => {
const node = nodeByTag.get(related.tag);
return node ? { node, related } : null;
const node = nodeByTag.get(related.tag)
return node ? { node, related } : null
})
.filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null)
.slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10)
: [],
[activeNode, nodeByTag, relatedTags, visibleEntries.length],
);
const activeAnchor = activeTag ? anchors[activeTag] : undefined;
const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count));
[activeNode, nodeByTag, relatedTags, visibleEntries.length]
)
const activeAnchor = activeTag ? anchors[activeTag] : undefined
const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count))
const connectedByTag = useMemo(
() => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])),
[visibleConnections],
);
[visibleConnections]
)
const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => {
if (element) {
buttonRefs.current.set(tag, element);
buttonRefs.current.set(tag, element)
} else {
buttonRefs.current.delete(tag);
buttonRefs.current.delete(tag)
}
}, []);
}, [])
const measureAnchors = useCallback(() => {
const canvas = canvasRef.current;
const canvas = canvasRef.current
if (!canvas || !activeTag) {
setAnchors({});
return;
setAnchors({})
return
}
const canvasRect = canvas.getBoundingClientRect();
const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)];
const nextAnchors: Record<string, TagAnchor> = {};
const canvasRect = canvas.getBoundingClientRect()
const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)]
const nextAnchors: Record<string, TagAnchor> = {}
for (const tag of tagsToMeasure) {
const element = buttonRefs.current.get(tag);
if (!element) continue;
const rect = element.getBoundingClientRect();
const element = buttonRefs.current.get(tag)
if (!element) continue
const rect = element.getBoundingClientRect()
nextAnchors[tag] = {
x: rect.left - canvasRect.left + rect.width / 2,
y: rect.top - canvasRect.top + rect.height / 2,
};
}
setAnchors(nextAnchors);
}, [activeTag, visibleConnections]);
}
setAnchors(nextAnchors)
}, [activeTag, visibleConnections])
useLayoutEffect(() => {
if (!activeTag) {
setAnchors({});
return;
setAnchors({})
return
}
let firstFrame = 0;
let secondFrame = 0;
const settleTimer = window.setTimeout(measureAnchors, 180);
let firstFrame = 0
let secondFrame = 0
const settleTimer = window.setTimeout(measureAnchors, 180)
firstFrame = window.requestAnimationFrame(() => {
measureAnchors();
secondFrame = window.requestAnimationFrame(measureAnchors);
});
measureAnchors()
secondFrame = window.requestAnimationFrame(measureAnchors)
})
return () => {
window.cancelAnimationFrame(firstFrame);
window.cancelAnimationFrame(secondFrame);
window.clearTimeout(settleTimer);
};
}, [activeTag, canvasSize.w, canvasSize.h, measureAnchors]);
window.cancelAnimationFrame(firstFrame)
window.cancelAnimationFrame(secondFrame)
window.clearTimeout(settleTimer)
}
}, [activeTag, canvasSize.w, canvasSize.h, measureAnchors])
return (
<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">
<defs>
<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)" />
</radialGradient>
</defs>
{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}
{visibleConnections.map(({ node, related }) => {
const from = activeAnchor;
const to = anchors[node.entry.tag];
if (!from || !to) return null;
const strength = related.shared_count / maxShared;
const from = activeAnchor
const to = anchors[node.entry.tag]
if (!from || !to) return null
const strength = related.shared_count / maxShared
return (
<g key={`${activeTag}:${node.entry.tag}`}>
<motion.line
@@ -282,7 +303,7 @@ export function TagAtlas({
vectorEffect="non-scaling-stroke"
initial={reducedMotion ? undefined : { pathLength: 0, opacity: 0 }}
animate={reducedMotion ? undefined : { pathLength: 1, opacity: 1 }}
transition={{ duration: 0.32, ease: "easeOut" }}
transition={{ duration: 0.32, ease: 'easeOut' }}
/>
{!reducedMotion ? (
<motion.line
@@ -302,32 +323,32 @@ export function TagAtlas({
transition={{
duration: 4.1 + seeded(node.index + 307) * 1.2,
repeat: Infinity,
ease: "easeInOut",
ease: 'easeInOut',
delay: seeded(node.index + 317) * 0.75,
}}
/>
) : null}
</g>
);
)
})}
</svg>
{nodes.map((node) => {
const isActive = activeTag === node.entry.tag;
const connectedRelated = connectedByTag.get(node.entry.tag);
const isRelated = connectedRelated !== undefined;
const dimmed = activeTag !== null && !isActive && !isRelated;
const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity);
const isActive = activeTag === node.entry.tag
const connectedRelated = connectedByTag.get(node.entry.tag)
const isRelated = connectedRelated !== undefined
const dimmed = activeTag !== null && !isActive && !isRelated
const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity)
return (
<Tooltip
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
delay={250}
>
<button
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 ${
isActive ? "before:opacity-100" : ""
isActive ? 'before:opacity-100' : ''
}`}
style={{
left: node.x - node.w / 2,
@@ -335,7 +356,8 @@ export function TagAtlas({
width: node.w,
minHeight: node.h,
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,
transform: `translate3d(0, 0, 0) scale(${isActive ? 1.045 : isRelated ? 1.015 : 1})`,
zIndex: isActive ? 30 : isRelated ? 20 : Math.round(node.ratio * 10),
@@ -346,21 +368,23 @@ export function TagAtlas({
onBlur={() => setActiveTag(null)}
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
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 ${
isActive || isRelated ? "opacity-100" : "opacity-0 group-hover:opacity-100"
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'
}`}
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>
</button>
</Tooltip>
);
)
})}
</div>
);
)
}
+171 -130
View File
@@ -1,19 +1,19 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { ExploreTagEntry } from "../../store";
import { Dropdown } from "../menu";
import { Tooltip } from "../Tooltip";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { ExploreTagEntry } from '../../store'
import { Dropdown } from '../menu'
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 }[] = [
{ value: "count_desc", label: "Most used" },
{ value: "count_asc", label: "Least used" },
{ value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" },
];
{ value: 'count_desc', label: 'Most used' },
{ value: 'count_asc', label: 'Least used' },
{ value: 'az', label: 'A-Z' },
{ value: 'za', label: 'Z-A' },
]
function AiSourceGlyph({ className = "" }: { className?: string }) {
function AiSourceGlyph({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
@@ -22,12 +22,15 @@ function AiSourceGlyph({ className = "" }: { className?: string }) {
strokeLinejoin="round"
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>
);
)
}
function RenameGlyph({ className = "" }: { className?: string }) {
function RenameGlyph({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<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" />
</svg>
);
)
}
function DeleteGlyph({ className = "" }: { className?: string }) {
function DeleteGlyph({ className = '' }: { className?: string }) {
return (
<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="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
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"
@@ -55,7 +63,7 @@ function DeleteGlyph({ className = "" }: { className?: string }) {
strokeWidth="1.35"
/>
</svg>
);
)
}
// Compact management tile for a single tag. Rename doubles as merge when the new
@@ -66,64 +74,67 @@ function TagManageTile({
onRename,
onDelete,
}: {
entry: ExploreTagEntry;
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
entry: ExploreTagEntry
onSearch: (tag: string) => void
onRename: (from: string, to: string) => Promise<void>
onDelete: (tag: string) => Promise<void>
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(entry.tag);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(entry.tag)
const [confirming, setConfirming] = useState(false)
const [busy, setBusy] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editing) {
setValue(entry.tag);
setTimeout(() => inputRef.current?.select(), 0);
setValue(entry.tag)
setTimeout(() => inputRef.current?.select(), 0)
}
}, [editing, entry.tag]);
}, [editing, entry.tag])
const commitRename = async () => {
const next = value.trim();
const next = value.trim()
if (!next || next === entry.tag) {
setEditing(false);
return;
setEditing(false)
return
}
setBusy(true);
setBusy(true)
try {
await onRename(entry.tag, next);
setEditing(false);
await onRename(entry.tag, next)
setEditing(false)
} finally {
setBusy(false);
setBusy(false)
}
}
};
const sourceLabel = entry.has_ai_source
? entry.has_user_source
? "Used by AI tags and user tags"
: "AI-generated tag"
: "User tag";
? 'Used by AI tags and user tags'
: 'AI-generated tag'
: 'User tag'
return (
<div
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
? "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]"
} ${editing || confirming ? "min-h-[82px]" : "min-h-[46px]"}`}
? '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]'
} ${editing || confirming ? 'min-h-[82px]' : 'min-h-[46px]'}`}
>
<div className="flex min-w-0 items-start gap-2">
{editing ? (
<input
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}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") setEditing(false);
if (e.key === 'Enter') {
e.preventDefault()
void commitRename()
}
if (e.key === 'Escape') setEditing(false)
}}
disabled={busy}
/>
@@ -137,16 +148,16 @@ function TagManageTile({
</button>
</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 ? (
<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" />
{entry.count.toLocaleString()}
</span>
</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()}
</span>
)}
@@ -174,7 +185,15 @@ function TagManageTile({
<div className="mt-2 flex items-center gap-1">
<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"
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}
>
Delete
@@ -188,7 +207,7 @@ function TagManageTile({
</button>
</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>
<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"
@@ -210,7 +229,7 @@ function TagManageTile({
</div>
)}
</div>
);
)
}
export function TagManageList({
@@ -221,88 +240,90 @@ export function TagManageList({
onResetAiTags,
scopeLabel,
}: {
entries: ExploreTagEntry[];
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
onResetAiTags: () => Promise<number>;
scopeLabel: string;
entries: ExploreTagEntry[]
onSearch: (tag: string) => void
onRename: (from: string, to: string) => Promise<void>
onDelete: (tag: string) => Promise<void>
onResetAiTags: () => Promise<number>
scopeLabel: string
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [sort, setSort] = useState<TagManageSort>("count_desc");
const [columns, setColumns] = useState(3);
const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetStatus, setResetStatus] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [query, setQuery] = useState('')
const [sort, setSort] = useState<TagManageSort>('count_desc')
const [columns, setColumns] = useState(3)
const [resetConfirming, setResetConfirming] = useState(false)
const [resetting, setResetting] = useState(false)
const [resetStatus, setResetStatus] = useState<string | null>(null)
useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const el = measureRef.current
if (!el) return
const update = () => {
const width = el.getBoundingClientRect().width;
setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const width = el.getBoundingClientRect().width
setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1)
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
const filteredEntries = useMemo(() => {
const needle = query.trim().toLowerCase();
const needle = query.trim().toLowerCase()
const filtered = needle
? entries.filter((entry) => entry.tag.toLowerCase().includes(needle))
: entries;
: entries
return [...filtered].sort((left, right) => {
switch (sort) {
case "count_asc":
return left.count - right.count || left.tag.localeCompare(right.tag);
case "az":
return left.tag.localeCompare(right.tag);
case "za":
return right.tag.localeCompare(left.tag);
case "count_desc":
case 'count_asc':
return left.count - right.count || left.tag.localeCompare(right.tag)
case 'az':
return left.tag.localeCompare(right.tag)
case 'za':
return right.tag.localeCompare(left.tag)
case 'count_desc':
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({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => 54,
overscan: 7,
});
const visibleItems = rowVirtualizer.getVirtualItems();
const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]);
})
const visibleItems = rowVirtualizer.getVirtualItems()
const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries])
const runResetAiTags = async () => {
if (!resetConfirming) {
setResetConfirming(true);
setResetStatus(`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`);
return;
setResetConfirming(true)
setResetStatus(
`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`
)
return
}
setResetting(true);
setResetStatus(null);
setResetting(true)
setResetStatus(null)
try {
const count = await onResetAiTags();
const count = await onResetAiTags()
setResetStatus(
count === 0
? "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.`,
);
? '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.`
)
} catch (error) {
setResetStatus(String(error));
setResetStatus(String(error))
} finally {
setResetting(false);
setResetConfirming(false);
setResetting(false)
setResetConfirming(false)
}
}
};
return (
<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="min-w-0">
<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">{totalUses.toLocaleString()} uses</span>
<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">
{totalUses.toLocaleString()} uses
</span>
{query.trim() ? (
<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
@@ -319,29 +344,32 @@ export function TagManageList({
) : null}
</div>
<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>
{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 className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
<button
className={`tag-manager-reset-button h-9 rounded-lg border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
resetConfirming
? "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"
? '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'
}`}
onClick={() => void runResetAiTags()}
disabled={resetting}
>
{resetting ? "Resetting..." : resetConfirming ? "Confirm reset" : "Reset AI tags"}
{resetting ? 'Resetting...' : resetConfirming ? 'Confirm reset' : 'Reset AI tags'}
</button>
{resetConfirming ? (
<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"
onClick={() => {
setResetConfirming(false);
setResetStatus(null);
setResetConfirming(false)
setResetStatus(null)
}}
disabled={resetting}
>
@@ -350,21 +378,32 @@ export function TagManageList({
) : null}
<div className="relative min-w-[220px] flex-1 sm:max-w-sm">
<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}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter tags"
/>
{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>
<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"
onClick={() => setQuery("")}
onClick={() => setQuery('')}
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M6 6l12 12M18 6L6 18" />
<svg
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>
</button>
</Tooltip>
@@ -382,7 +421,11 @@ export function TagManageList({
</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">
{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">
@@ -394,14 +437,14 @@ export function TagManageList({
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{visibleItems.map((virtualRow) => {
const start = virtualRow.index * columns;
const rowEntries = filteredEntries.slice(start, start + columns);
const start = virtualRow.index * columns
const rowEntries = filteredEntries.slice(start, start + columns)
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
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={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
transform: `translateY(${virtualRow.start}px)`,
@@ -417,14 +460,12 @@ export function TagManageList({
/>
))}
</div>
);
)
})}
</div>
)}
</div>
</div>
</div>
);
)
}
+25 -25
View File
@@ -1,34 +1,34 @@
export const ACCENTS = [
"#60a5fa",
"#c084fc",
"#4ade80",
"#fbbf24",
"#f472b4",
"#2dd4bf",
"#fb923c",
"#a78bfa",
"#34d399",
"#f87171",
];
'#60a5fa',
'#c084fc',
'#4ade80',
'#fbbf24',
'#f472b4',
'#2dd4bf',
'#fb923c',
'#a78bfa',
'#34d399',
'#f87171',
]
// Darker variants of each accent for the light theme -- the bright originals are
// tuned for dark cards and wash out on the cream background.
export const LIGHT_ACCENTS = [
"#2563eb",
"#9333ea",
"#16a34a",
"#d97706",
"#db2777",
"#0d9488",
"#ea580c",
"#7c3aed",
"#059669",
"#dc2626",
];
'#2563eb',
'#9333ea',
'#16a34a',
'#d97706',
'#db2777',
'#0d9488',
'#ea580c',
'#7c3aed',
'#059669',
'#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 {
const x = Math.sin(n * 9301 + 49297) * 233280;
return x - Math.floor(x);
const x = Math.sin(n * 9301 + 49297) * 233280
return x - Math.floor(x)
}
+24 -20
View File
@@ -1,6 +1,6 @@
import { DirEntry } from "../../store";
import { Tooltip } from "../Tooltip";
import { CheckIcon, ChevronRightIcon } from "../icons";
import { DirEntry } from '../../store'
import { Tooltip } from '../Tooltip'
import { CheckIcon, ChevronRightIcon } from '../icons'
export function FolderRow({
entry,
@@ -9,29 +9,29 @@ export function FolderRow({
onToggle,
onNavigate,
}: {
entry: DirEntry;
selected: boolean;
alreadyAdded: boolean;
onToggle: () => void;
onNavigate: () => void;
entry: DirEntry
selected: boolean
alreadyAdded: boolean
onToggle: () => void
onNavigate: () => void
}) {
return (
<div
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
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
? "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"
: "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: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)]'
: '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
type="button"
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
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"
: "border-white/15 bg-white/[0.035] text-transparent hover:border-white/30 light-theme:border-gray-700/50 light-theme:bg-gray-950"
} ${alreadyAdded ? "cursor-not-allowed opacity-40" : ""}`}
? 'light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white border-white/30 bg-gray-200 text-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' : ''}`}
onClick={onToggle}
disabled={alreadyAdded}
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"
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" />
</svg>
<span className="truncate text-sm">{entry.name}</span>
@@ -53,20 +57,20 @@ export function FolderRow({
</Tooltip>
{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
</span>
) : null}
<Tooltip label={entry.has_children ? "Open folder" : "No subfolders"} anchorToCursor>
<Tooltip label={entry.has_children ? 'Open folder' : 'No subfolders'} anchorToCursor>
<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}
>
<ChevronRightIcon className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} />
<ChevronRightIcon className={`h-4 w-4 ${entry.has_children ? '' : 'opacity-45'}`} />
</button>
</Tooltip>
</div>
);
)
}
@@ -1,26 +1,26 @@
import { Tooltip } from "../Tooltip";
import { CloseIcon } from "../icons";
import { folderName } from "./pathUtils";
import { Tooltip } from '../Tooltip'
import { CloseIcon } from '../icons'
import { folderName } from './pathUtils'
export function StagedFoldersPanel({
stagedPaths,
onRemove,
onClear,
}: {
stagedPaths: string[];
onRemove: (path: string) => void;
onClear: () => void;
stagedPaths: string[]
onRemove: (path: string) => void
onClear: () => void
}) {
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">
<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">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">
<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="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 tracking-[0.16em] text-gray-500 uppercase">
Folders to add ({stagedPaths.length})
</p>
{stagedPaths.length > 0 ? (
<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}
>
Clear all
@@ -30,9 +30,9 @@ export function StagedFoldersPanel({
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{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="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.
</p>
</div>
@@ -40,18 +40,24 @@ export function StagedFoldersPanel({
<div className="space-y-2">
{stagedPaths.map((path) => (
<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">
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<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"
>
<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>
<div className="min-w-0 flex-1">
<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>
<Tooltip label="Remove from folders to add" anchorToCursor>
<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)}
aria-label={`Remove ${path} from folders to add`}
>
@@ -65,5 +71,5 @@ export function StagedFoldersPanel({
)}
</div>
</aside>
);
)
}
+7 -7
View File
@@ -1,13 +1,13 @@
import { FolderAddResult } from "../../store";
import { FolderAddResult } from '../../store'
export function StatusLine({ results }: { results: FolderAddResult[] | null }) {
if (!results) return null;
const added = results.filter((result) => result.status === "added").length;
const skipped = results.filter((result) => result.status === "skipped").length;
const failed = results.filter((result) => result.status === "error").length;
if (!results) return null
const added = results.filter((result) => result.status === 'added').length
const skipped = results.filter((result) => result.status === 'skipped').length
const failed = results.filter((result) => result.status === 'error').length
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}.
</p>
);
)
}
+35 -32
View File
@@ -1,66 +1,69 @@
export interface Breadcrumb {
label: string;
path: string | null;
label: string
path: string | null
}
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
}
export function cleanAddressInput(path: string): string {
const trimmed = path.trim();
const trimmed = path.trim()
if (trimmed.length >= 2) {
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
return trimmed.slice(1, -1).trim();
const first = trimmed[0]
const last = trimmed[trimmed.length - 1]
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return trimmed.slice(1, -1).trim()
}
}
return trimmed;
return trimmed
}
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)) {
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 {
const trimmed = path.replace(/[\\/]+$/, "");
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : path;
const trimmed = path.replace(/[\\/]+$/, '')
const parts = trimmed.split(/[\\/]+/).filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : path
}
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 normalized = path.replace(/[\\/]+$/, "");
const windowsDrive = normalized.match(/^[A-Za-z]:/);
const separator = path.includes('\\') ? '\\' : '/'
const normalized = path.replace(/[\\/]+$/, '')
const windowsDrive = normalized.match(/^[A-Za-z]:/)
if (windowsDrive) {
const drive = windowsDrive[0];
const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean);
let current = drive;
const drive = windowsDrive[0]
const rest = normalized
.slice(2)
.split(/[\\/]+/)
.filter(Boolean)
let current = drive
return [
{ label: "This PC", path: null },
{ label: 'This PC', path: null },
{ label: drive, path: drive },
...rest.map((part) => {
current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`;
return { label: part, path: current };
current = current.endsWith('\\') ? `${current}${part}` : `${current}\\${part}`
return { label: part, path: current }
}),
];
]
}
const parts = normalized.split(/[\\/]+/).filter(Boolean);
let current = separator === "/" ? "" : "";
const parts = normalized.split(/[\\/]+/).filter(Boolean)
let current = separator === '/' ? '' : ''
return [
{ label: "/", path: null },
{ label: '/', path: null },
...parts.map((part) => {
current = `${current}/${part}`;
return { label: part, path: current };
current = `${current}/${part}`
return { label: part, path: current }
}),
];
]
}
+127 -120
View File
@@ -1,183 +1,190 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { DirListing, FolderAddResult, useGalleryStore } from "../../store";
import { useEffect, useMemo, useRef, useState } from 'react'
import { DirListing, FolderAddResult, useGalleryStore } from '../../store'
import {
buildBreadcrumbs,
cleanAddressInput,
friendlyDirectoryError,
normalizePath,
} from "./pathUtils";
} from './pathUtils'
export function useFolderPicker() {
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const folders = useGalleryStore((state) => state.folders);
const listDirectories = useGalleryStore((state) => state.listDirectories);
const addFolders = useGalleryStore((state) => state.addFolders);
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen)
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen)
const folders = useGalleryStore((state) => state.folders)
const listDirectories = useGalleryStore((state) => state.listDirectories)
const addFolders = useGalleryStore((state) => state.addFolders)
const [listing, setListing] = useState<DirListing | null>(null);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [addressDraft, setAddressDraft] = useState("");
const [addressEditing, setAddressEditing] = useState(false);
const [stagedPaths, setStagedPaths] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<FolderAddResult[] | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const addressInputRef = useRef<HTMLInputElement>(null);
const [listing, setListing] = useState<DirListing | null>(null)
const [currentPath, setCurrentPath] = useState<string | null>(null)
const [addressDraft, setAddressDraft] = useState('')
const [addressEditing, setAddressEditing] = useState(false)
const [stagedPaths, setStagedPaths] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [adding, setAdding] = useState(false)
const [error, setError] = useState<string | null>(null)
const [results, setResults] = useState<FolderAddResult[] | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const addressInputRef = useRef<HTMLInputElement>(null)
const libraryPaths = useMemo(() => new Set(folders.map((folder) => normalizePath(folder.path))), [folders]);
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths]);
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current]);
const libraryPaths = useMemo(
() => new Set(folders.map((folder) => normalizePath(folder.path))),
[folders]
)
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths])
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current])
useEffect(() => {
if (!folderPickerOpen) return;
let cancelled = false;
setLoading(true);
setError(null);
if (!folderPickerOpen) return
let cancelled = false
setLoading(true)
setError(null)
void listDirectories(currentPath)
.then((nextListing) => {
if (cancelled) return;
setListing(nextListing);
setAddressDraft(nextListing.current ?? "");
setAddressEditing(false);
scrollRef.current?.scrollTo({ top: 0, left: 0 });
if (cancelled) return
setListing(nextListing)
setAddressDraft(nextListing.current ?? '')
setAddressEditing(false)
scrollRef.current?.scrollTo({ top: 0, left: 0 })
})
.catch((loadError) => {
if (cancelled) return;
setListing({ current: currentPath, parent: null, entries: [] });
setError(friendlyDirectoryError(loadError));
if (cancelled) return
setListing({ current: currentPath, parent: null, entries: [] })
setError(friendlyDirectoryError(loadError))
})
.finally(() => {
if (!cancelled) setLoading(false);
});
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true;
};
}, [currentPath, folderPickerOpen, listDirectories]);
cancelled = true
}
}, [currentPath, folderPickerOpen, listDirectories])
useEffect(() => {
if (!folderPickerOpen) return;
if (!folderPickerOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (event.key === 'Escape') {
if (addressEditing) {
setAddressDraft(listing?.current ?? "");
setAddressEditing(false);
return;
setAddressDraft(listing?.current ?? '')
setAddressEditing(false)
return
}
setFolderPickerOpen(false);
setFolderPickerOpen(false)
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen]);
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen])
useEffect(() => {
if (!addressEditing) return;
if (!addressEditing) return
requestAnimationFrame(() => {
addressInputRef.current?.focus();
addressInputRef.current?.select();
});
}, [addressEditing]);
addressInputRef.current?.focus()
addressInputRef.current?.select()
})
}, [addressEditing])
useEffect(() => {
if (folderPickerOpen) return;
setCurrentPath(null);
setAddressDraft("");
setAddressEditing(false);
setListing(null);
setStagedPaths([]);
setError(null);
setResults(null);
setAdding(false);
}, [folderPickerOpen]);
if (folderPickerOpen) return
setCurrentPath(null)
setAddressDraft('')
setAddressEditing(false)
setListing(null)
setStagedPaths([])
setError(null)
setResults(null)
setAdding(false)
}, [folderPickerOpen])
const entries = listing?.entries ?? [];
const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? ""));
const normalizedAddressPath = addressPath ? normalizePath(addressPath) : "";
const addressAlreadyAdded = normalizedAddressPath ? libraryPaths.has(normalizedAddressPath) : false;
const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false;
const entries = listing?.entries ?? []
const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? ''))
const normalizedAddressPath = addressPath ? normalizePath(addressPath) : ''
const addressAlreadyAdded = normalizedAddressPath
? libraryPaths.has(normalizedAddressPath)
: false
const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false
const togglePath = (path: string) => {
const normalized = normalizePath(path);
if (libraryPaths.has(normalized)) return;
setResults(null);
const normalized = normalizePath(path)
if (libraryPaths.has(normalized)) return
setResults(null)
setStagedPaths((current) => {
const exists = current.some((staged) => normalizePath(staged) === normalized);
return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path];
});
};
const exists = current.some((staged) => normalizePath(staged) === normalized)
return exists
? current.filter((staged) => normalizePath(staged) !== normalized)
: [...current, path]
})
}
const stagePath = (path: string) => {
const cleaned = cleanAddressInput(path);
const cleaned = cleanAddressInput(path)
if (!cleaned) {
setError("Enter a folder path first.");
return;
setError('Enter a folder path first.')
return
}
const normalized = normalizePath(cleaned);
const normalized = normalizePath(cleaned)
if (libraryPaths.has(normalized)) {
setError("That folder is already in your library.");
return;
setError('That folder is already in your library.')
return
}
if (stagedSet.has(normalized)) {
setError("That folder is already selected.");
return;
setError('That folder is already selected.')
return
}
setError(null);
setResults(null);
setStagedPaths((current) => [...current, cleaned]);
};
setError(null)
setResults(null)
setStagedPaths((current) => [...current, cleaned])
}
const navigateToAddress = () => {
const cleaned = cleanAddressInput(addressDraft);
setResults(null);
setError(null);
setCurrentPath(cleaned || null);
};
const cleaned = cleanAddressInput(addressDraft)
setResults(null)
setError(null)
setCurrentPath(cleaned || null)
}
const updateAddressDraft = (nextDraft: string) => {
setAddressDraft(nextDraft);
setResults(null);
};
setAddressDraft(nextDraft)
setResults(null)
}
const beginAddressEdit = () => {
setAddressDraft(listing?.current ?? "");
setAddressEditing(true);
};
setAddressDraft(listing?.current ?? '')
setAddressEditing(true)
}
const removeStagedPath = (path: string) => {
const normalized = normalizePath(path);
setResults(null);
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized));
};
const normalized = normalizePath(path)
setResults(null)
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized))
}
const clearStagedPaths = () => {
setResults(null);
setStagedPaths([]);
};
setResults(null)
setStagedPaths([])
}
const confirmAdd = async () => {
if (stagedPaths.length === 0 || adding) return;
setAdding(true);
setError(null);
if (stagedPaths.length === 0 || adding) return
setAdding(true)
setError(null)
try {
const addResults = await addFolders(stagedPaths);
const failed = addResults.filter((result) => result.status === "error");
setResults(addResults);
const addResults = await addFolders(stagedPaths)
const failed = addResults.filter((result) => result.status === 'error')
setResults(addResults)
if (failed.length > 0) {
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error"));
setError(failed.map((failure) => failure.data).join("; "));
return;
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === 'error'))
setError(failed.map((failure) => failure.data).join('; '))
return
}
setFolderPickerOpen(false);
setFolderPickerOpen(false)
} catch (addError) {
setError(addError instanceof Error ? addError.message : String(addError));
setError(addError instanceof Error ? addError.message : String(addError))
} finally {
setAdding(false);
setAdding(false)
}
}
};
return {
adding,
@@ -208,5 +215,5 @@ export function useFolderPicker() {
stagedSet,
togglePath,
updateAddressDraft,
};
}
}
+32 -32
View File
@@ -1,12 +1,12 @@
import { ParsedSearch } from "../../store";
import { PhotoIcon } from "../icons";
import { ParsedSearch } from '../../store'
import { PhotoIcon } from '../icons'
export function GalleryLoadingState({
isSimilarResults,
parsedSearch,
}: {
isSimilarResults: boolean;
parsedSearch: ParsedSearch;
isSimilarResults: boolean
parsedSearch: ParsedSearch
}) {
return (
<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" />
<p className="mt-4 text-sm font-medium text-white/40">
{isSimilarResults
? "Finding similar images"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? 'Finding similar images'
: parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? `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}"`
: "Loading media"}
: 'Loading media'}
</p>
<p className="mt-1 text-xs text-white/20">
{isSimilarResults
? "Comparing visual embeddings"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Semantic search can take a little longer than filename search"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Matching against AI and user tags"
: "Fetching results"}
? 'Comparing visual embeddings'
: parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? 'Semantic search can take a little longer than filename search'
: parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? 'Matching against AI and user tags'
: 'Fetching results'}
</p>
</div>
</div>
);
)
}
export function GalleryEmptyState({
@@ -40,9 +40,9 @@ export function GalleryEmptyState({
isSimilarResults,
parsedSearch,
}: {
imageLoadError: string | null;
isSimilarResults: boolean;
parsedSearch: ParsedSearch;
imageLoadError: string | null
isSimilarResults: boolean
parsedSearch: ParsedSearch
}) {
return (
<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} />
<p className="text-sm font-medium text-white/30">
{imageLoadError
? "Could not load results"
? 'Could not load results'
: isSimilarResults
? "No similar images found"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "No semantic matches found"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "No tag matches found"
: "No media found"}
? 'No similar images found'
: parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? 'No semantic matches found'
: parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? 'No tag matches found'
: 'No media found'}
</p>
<p className="mt-1 text-xs text-white/15">
{imageLoadError
? imageLoadError
: isSimilarResults
? "This item may be visually isolated, or more embeddings may need to finish processing"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Try a broader phrase, or wait for more embeddings to finish processing"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Try a shorter tag, or wait for more tagging jobs to finish"
: "Try adjusting your filters or add a new folder"}
? 'This item may be visually isolated, or more embeddings may need to finish processing'
: parsedSearch.mode === 'semantic' && parsedSearch.query.length > 0
? 'Try a broader phrase, or wait for more embeddings to finish processing'
: parsedSearch.mode === 'tag' && parsedSearch.query.length > 0
? 'Try a shorter tag, or wait for more tagging jobs to finish'
: 'Try adjusting your filters or add a new folder'}
</p>
</div>
</div>
);
)
}
+50 -46
View File
@@ -1,36 +1,36 @@
import { useState } from "react";
import { ImageRecord, useGalleryStore } from "../../store";
import { mediaSrc } from "../../lib/mediaSrc";
import { Tooltip } from "../Tooltip";
import { CheckIcon, PhotoIcon, PlayIcon, StarIcon, WarningIcon } from "../icons";
import { formatDuration } from "./format";
import { TruncatedFilename } from "./TruncatedFilename";
import { useState } from 'react'
import { ImageRecord, useGalleryStore } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from '../Tooltip'
import { CheckIcon, PhotoIcon, PlayIcon, StarIcon, WarningIcon } from '../icons'
import { formatDuration } from './format'
import { TruncatedFilename } from './TruncatedFilename'
export function ImageTile({
image,
onClick,
onContextMenu,
}: {
image: ImageRecord;
onClick: () => void;
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
image: ImageRecord
onClick: () => void
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void
}) {
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id));
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0);
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected);
const canFindSimilar = image.embedding_status === "ready";
const [loaded, setLoaded] = useState(false)
const [errored, setErrored] = useState(false)
const findSimilar = useGalleryStore((state) => state.findSimilar)
const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id))
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0)
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected)
const canFindSimilar = image.embedding_status === 'ready'
const src = mediaSrc(image.thumbnail_path);
const src = mediaSrc(image.thumbnail_path)
return (
<div
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}
>
<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"
aria-label={`Open ${image.filename}`}
onClick={(event) => {
event.stopPropagation();
if (selectionActive) toggleGallerySelected(image.id);
else onClick();
event.stopPropagation()
if (selectionActive) toggleGallerySelected(image.id)
else onClick()
}}
onDoubleClick={(event) => {
event.stopPropagation();
onClick();
event.stopPropagation()
onClick()
}}
/>
<button
type="button"
role="checkbox"
aria-checked={selected}
aria-label={selected ? "Deselect" : "Select"}
className="group/cb absolute left-0 top-0 z-20 h-11 w-11 cursor-pointer"
aria-label={selected ? 'Deselect' : 'Select'}
className="group/cb absolute top-0 left-0 z-20 h-11 w-11 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleGallerySelected(image.id);
event.stopPropagation()
toggleGallerySelected(image.id)
}}
>
<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
? "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-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'
}`}
>
<CheckIcon className="h-3 w-3" strokeWidth={3} />
@@ -76,7 +76,7 @@ export function ImageTile({
src={src}
alt={image.filename}
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]`}
loading="lazy"
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">
{image.media_kind === "video" ? (
{image.media_kind === 'video' ? (
<PlayIcon className="h-7 w-7" />
) : (
<PhotoIcon className="h-7 w-7" strokeWidth={1} />
@@ -93,7 +93,7 @@ export function ImageTile({
</div>
)}
{image.media_kind === "video" ? (
{image.media_kind === 'video' ? (
<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">
<PlayIcon className="h-5 w-5" />
@@ -101,9 +101,13 @@ export function ImageTile({
</div>
) : null}
<div className="pointer-events-none absolute right-2 top-2 flex flex-col items-end gap-1">
{image.embedding_status === "failed" ? (
<Tooltip label={image.embedding_error ?? "Embedding failed"} followCursor className="pointer-events-auto">
<div className="pointer-events-none absolute top-2 right-2 flex flex-col items-end gap-1">
{image.embedding_status === 'failed' ? (
<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">
<WarningIcon className="h-2.5 w-2.5 shrink-0" strokeWidth={2.5} />
</div>
@@ -123,7 +127,7 @@ export function ImageTile({
))}
</div>
) : 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">
{formatDuration(image.duration_ms)}
</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="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} />
<div className="mt-1.5 flex items-center justify-between gap-2">
{image.rating > 0 ? (
@@ -147,13 +151,13 @@ export function ImageTile({
<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 ${
canFindSimilar
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
: "cursor-not-allowed bg-white/5 text-white/30"
? 'bg-white/10 text-white/80 hover:bg-white/20 hover:text-white'
: 'cursor-not-allowed bg-white/5 text-white/30'
}`}
onClick={(event) => {
event.stopPropagation();
if (!canFindSimilar) return;
findSimilar(image.id, image.folder_id);
event.stopPropagation()
if (!canFindSimilar) return
findSimilar(image.id, image.folder_id)
}}
disabled={!canFindSimilar}
>
@@ -162,5 +166,5 @@ export function ImageTile({
</div>
</div>
</div>
);
)
}
+16 -16
View File
@@ -1,34 +1,34 @@
import { useLayoutEffect, useRef, useState } from "react";
import { Tooltip } from "../Tooltip";
import { useLayoutEffect, useRef, useState } from 'react'
import { Tooltip } from '../Tooltip'
export function TruncatedFilename({ filename }: { filename: string }) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const textRef = useRef<HTMLParagraphElement>(null)
const [isTruncated, setIsTruncated] = useState(false)
useLayoutEffect(() => {
const text = textRef.current;
if (!text) return;
const text = textRef.current
if (!text) return
const update = () => {
setIsTruncated(text.scrollWidth > text.clientWidth);
};
setIsTruncated(text.scrollWidth > text.clientWidth)
}
update();
update()
const observer = new ResizeObserver(update);
observer.observe(text);
return () => observer.disconnect();
}, [filename]);
const observer = new ResizeObserver(update)
observer.observe(text)
return () => observer.disconnect()
}, [filename])
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}
</p>
);
)
return (
<Tooltip label={filename} delay={500} block followCursor disabled={!isTruncated}>
{label}
</Tooltip>
);
)
}
+7 -7
View File
@@ -1,11 +1,11 @@
export function formatDuration(durationMs: number | null): string | null {
if (!durationMs || durationMs <= 0) return null;
const totalSeconds = Math.floor(durationMs / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (!durationMs || durationMs <= 0) return null
const totalSeconds = Math.floor(durationMs / 1000)
const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60) % 60
const hours = Math.floor(totalSeconds / 3600)
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 {
className?: string;
strokeWidth?: number;
className?: string
strokeWidth?: number
}
function strokeIcon(d: string, defaultStrokeWidth: number, displayName: string) {
function Icon({ className = "", strokeWidth = defaultStrokeWidth }: IconProps) {
function Icon({ className = '', strokeWidth = defaultStrokeWidth }: IconProps) {
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} />
</svg>
);
)
}
Icon.displayName = displayName;
return Icon;
Icon.displayName = displayName
return Icon
}
export const CheckIcon = strokeIcon("M5 13l4 4L19 7", 2.5, "CheckIcon");
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 ChevronRightIcon = strokeIcon("M9 5l7 7-7 7", 2, "ChevronRightIcon");
export const PlusIcon = strokeIcon("M12 4v16m8-8H4", 1.75, "PlusIcon");
export const CheckIcon = strokeIcon('M5 13l4 4L19 7', 2.5, 'CheckIcon')
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 ChevronRightIcon = strokeIcon('M9 5l7 7-7 7', 2, 'ChevronRightIcon')
export const PlusIcon = strokeIcon('M12 4v16m8-8H4', 1.75, 'PlusIcon')
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,
"PhotoIcon",
);
'PhotoIcon'
)
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,
"FolderIcon",
);
'FolderIcon'
)
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,
"WarningIcon",
);
'WarningIcon'
)
export function StarIcon({ className = "" }: { className?: string }) {
export function StarIcon({ className = '' }: { className?: string }) {
return (
<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" />
</svg>
);
)
}
export function PlayIcon({ className = "" }: { className?: string }) {
export function PlayIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
);
)
}
+36 -26
View File
@@ -1,26 +1,34 @@
import { useState } from "react";
import { Album } from "../../store";
import { useState } from 'react'
import { Album } from '../../store'
interface LightboxAlbumMenuProps {
imageId: number;
albums: Album[];
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>;
createAlbum: (name: string) => Promise<Album>;
imageId: number
albums: Album[]
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>
createAlbum: (name: string) => Promise<Album>
}
export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }: LightboxAlbumMenuProps) {
const [albumMenuOpen, setAlbumMenuOpen] = useState(false);
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null);
const [newAlbumName, setNewAlbumName] = useState("");
const [albumAdding, setAlbumAdding] = useState(false);
export function LightboxAlbumMenu({
imageId,
albums,
addToAlbum,
createAlbum,
}: LightboxAlbumMenuProps) {
const [albumMenuOpen, setAlbumMenuOpen] = useState(false)
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null)
const [newAlbumName, setNewAlbumName] = useState('')
const [albumAdding, setAlbumAdding] = useState(false)
return (
<div className="relative">
<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
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
</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="max-h-40 overflow-y-auto">
{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) => (
<button
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"
onClick={() => {
if (albumAdding) return;
setAlbumAdding(true);
if (albumAdding) return
setAlbumAdding(true)
void addToAlbum(album.id, [imageId])
.then(() => setAlbumAddedTo(album.id))
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
.finally(() => setAlbumAdding(false))
}}
disabled={albumAdding}
>
@@ -58,18 +68,18 @@ export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }:
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-1.5"
onSubmit={(event) => {
event.preventDefault();
const name = newAlbumName.trim();
if (!name || albumAdding) return;
setAlbumAdding(true);
event.preventDefault()
const name = newAlbumName.trim()
if (!name || albumAdding) return
setAlbumAdding(true)
void createAlbum(name)
.then(async (album) => {
await addToAlbum(album.id, [imageId]);
setAlbumAddedTo(album.id);
setNewAlbumName("");
await addToAlbum(album.id, [imageId])
setAlbumAddedTo(album.id)
setNewAlbumName('')
})
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
.finally(() => setAlbumAdding(false))
}}
>
<input
@@ -90,5 +100,5 @@ export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }:
</div>
) : null}
</div>
);
)
}
+188 -116
View File
@@ -1,44 +1,47 @@
import { MutableRefObject } from "react";
import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { Album, ImageExif, ImageRecord, ImageTag } from "../../store";
import { Tooltip } from "../Tooltip";
import { CloseIcon, StarIcon } from "../icons";
import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from "./format";
import { LightboxAlbumMenu } from "./LightboxAlbumMenu";
import { MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { Album, ImageExif, ImageRecord, ImageTag } from '../../store'
import { Tooltip } from '../Tooltip'
import { CloseIcon, StarIcon } from '../icons'
import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from './format'
import { LightboxAlbumMenu } from './LightboxAlbumMenu'
interface LightboxDetailsPanelProps {
selectedImage: ImageRecord;
currentIndex: number;
imageCount: number;
imageTags: ImageTag[];
setImageTags: React.Dispatch<React.SetStateAction<ImageTag[]>>;
imageExif: ImageExif | null;
tagInput: string;
setTagInput: React.Dispatch<React.SetStateAction<string>>;
tagAdding: boolean;
setTagAdding: React.Dispatch<React.SetStateAction<boolean>>;
tagsExpanded: boolean;
setTagsExpanded: React.Dispatch<React.SetStateAction<boolean>>;
taggingQueued: boolean;
setTaggingQueued: React.Dispatch<React.SetStateAction<boolean>>;
currentImageIdRef: MutableRefObject<number | null>;
albums: Album[];
canFindSimilar: boolean;
canSearchRegion: boolean;
regionSelectMode: boolean;
regionSearching: boolean;
taggerReady: boolean;
taggerButtonTooltip: string;
closeImage: () => void;
findSimilar: (imageId: number, sourceFolderId: number | null) => Promise<void>;
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
removeTag: (tagId: number) => Promise<void>;
queueTaggingForImage: (imageId: number) => Promise<number>;
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>;
createAlbum: (name: string) => Promise<Album>;
onToggleRegionMode: () => void;
selectedImage: ImageRecord
currentIndex: number
imageCount: number
imageTags: ImageTag[]
setImageTags: React.Dispatch<React.SetStateAction<ImageTag[]>>
imageExif: ImageExif | null
tagInput: string
setTagInput: React.Dispatch<React.SetStateAction<string>>
tagAdding: boolean
setTagAdding: React.Dispatch<React.SetStateAction<boolean>>
tagsExpanded: boolean
setTagsExpanded: React.Dispatch<React.SetStateAction<boolean>>
taggingQueued: boolean
setTaggingQueued: React.Dispatch<React.SetStateAction<boolean>>
currentImageIdRef: MutableRefObject<number | null>
albums: Album[]
canFindSimilar: boolean
canSearchRegion: boolean
regionSelectMode: boolean
regionSearching: boolean
taggerReady: boolean
taggerButtonTooltip: string
closeImage: () => void
findSimilar: (imageId: number, sourceFolderId: number | null) => Promise<void>
updateImageDetails: (
imageId: number,
updates: { favorite?: boolean; rating?: number }
) => Promise<void>
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>
removeTag: (tagId: number) => Promise<void>
queueTaggingForImage: (imageId: number) => Promise<number>
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>
createAlbum: (name: string) => Promise<Album>
onToggleRegionMode: () => void
}
export function LightboxDetailsPanel({
@@ -74,7 +77,7 @@ export function LightboxDetailsPanel({
createAlbum,
onToggleRegionMode,
}: LightboxDetailsPanelProps) {
const aiRating = selectedImage.ai_rating ? ratingPill(selectedImage.ai_rating) : null;
const aiRating = selectedImage.ai_rating ? ratingPill(selectedImage.ai_rating) : null
const hasCameraInfo =
imageExif &&
(imageExif.make ||
@@ -84,7 +87,7 @@ export function LightboxDetailsPanel({
imageExif.exposure_time ||
imageExif.iso ||
imageExif.focal_length ||
(imageExif.gps_lat != null && imageExif.gps_lon != null));
(imageExif.gps_lat != null && imageExif.gps_lon != null))
return (
<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>
</div>
<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
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 })}
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 })
}
>
<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" />
</svg>
</button>
</Tooltip>
<Tooltip label={canFindSimilar ? "Find similar images" : "Embeddings not ready"} followCursor>
<Tooltip
label={canFindSimilar ? 'Find similar images' : 'Embeddings not ready'}
followCursor
>
<button
className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${
canFindSimilar
? "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"
? 'border-white/10 bg-white/5 text-gray-300 hover:text-white'
: 'cursor-not-allowed border-white/5 bg-white/[0.03] text-gray-600'
}`}
onClick={() => {
if (!canFindSimilar) return;
void findSimilar(selectedImage.id, selectedImage.folder_id);
if (!canFindSimilar) return
void findSimilar(selectedImage.id, selectedImage.folder_id)
}}
disabled={!canFindSimilar}
>
<svg className="h-4 w-4 shrink-0" 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
className="h-4 w-4 shrink-0"
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>
<span className="hidden lg:inline">{canFindSimilar ? "Similar" : "Embeddings not ready"}</span>
<span className="hidden lg:inline">
{canFindSimilar ? 'Similar' : 'Embeddings not ready'}
</span>
</button>
</Tooltip>
</div>
@@ -132,25 +157,39 @@ export function LightboxDetailsPanel({
{canSearchRegion && (
<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
className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${
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
? "border-white/5 bg-white/[0.03] text-gray-500 cursor-not-allowed"
: "border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white"
? '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'
}`}
onClick={() => {
if (regionSearching) return;
onToggleRegionMode();
if (regionSearching) return
onToggleRegionMode()
}}
disabled={regionSearching}
>
{regionSearching ? (
<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">
<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" />
</svg>
Searching region
@@ -163,7 +202,12 @@ export function LightboxDetailsPanel({
) : (
<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">
<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>
Search within image
</span>
@@ -173,23 +217,30 @@ export function LightboxDetailsPanel({
</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="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">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
const rating = index + 1
return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor delay={750}>
<Tooltip
key={rating}
label={`Set ${rating} star rating`}
followCursor
delay={750}
>
<button
className="rounded-md p-1"
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>
</Tooltip>
);
)
})}
{selectedImage.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
@@ -205,31 +256,31 @@ export function LightboxDetailsPanel({
</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">
{selectedImage.width && selectedImage.height
? `${selectedImage.width} x ${selectedImage.height}px`
: "Pending / unavailable"}
: 'Pending / unavailable'}
</p>
</div>
{selectedImage.media_kind === "video" ? (
{selectedImage.media_kind === 'video' ? (
<>
<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>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Video codec</p>
<p className="text-white">{selectedImage.video_codec ?? "Pending / unavailable"}</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>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Audio codec</p>
<p className="text-white">{selectedImage.audio_codec ?? "None / unavailable"}</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>
</div>
{selectedImage.metadata_error ? (
<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>
</div>
) : null}
@@ -237,20 +288,22 @@ export function LightboxDetailsPanel({
) : null}
<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>
</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>
</div>
<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>
</div>
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Embedding</p>
<p className="text-white">{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}</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>
{selectedImage.embedding_error ? (
<p className="mt-1 text-xs text-amber-300">{selectedImage.embedding_error}</p>
) : null}
@@ -259,24 +312,26 @@ export function LightboxDetailsPanel({
<div>
<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">
{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}
</span>
) : null}
{selectedImage.media_kind === "image" ? (
{selectedImage.media_kind === 'image' ? (
<Tooltip label={taggerButtonTooltip} followCursor>
<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"
disabled={!taggerReady || taggingQueued}
onClick={() => {
setTaggingQueued(true);
void queueTaggingForImage(selectedImage.id).catch(() => undefined);
setTaggingQueued(true)
void queueTaggingForImage(selectedImage.id).catch(() => undefined)
}}
>
{taggingQueued ? "Queued" : "AI tags"}
{taggingQueued ? 'Queued' : 'AI tags'}
</button>
</Tooltip>
) : null}
@@ -289,25 +344,29 @@ export function LightboxDetailsPanel({
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((tag) => (
<Tooltip
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
disabled={tag.source !== "ai" || tag.confidence === null}
disabled={tag.source !== 'ai' || tag.confidence === null}
>
<span
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
tag.source === "ai"
? "border-sky-400/20 bg-sky-500/8 text-sky-300"
: "border-white/10 bg-white/5 text-gray-300"
tag.source === 'ai'
? 'border-sky-400/20 bg-sky-500/8 text-sky-300'
: 'border-white/10 bg-white/5 text-gray-300'
}`}
>
{tag.tag}
<Tooltip label="Remove tag" followCursor>
<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={() => {
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" />
@@ -319,10 +378,10 @@ export function LightboxDetailsPanel({
</div>
{imageTags.length > 8 && (
<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)}
>
{tagsExpanded ? "Show less" : `+${imageTags.length - 8} more`}
{tagsExpanded ? 'Show less' : `+${imageTags.length - 8} more`}
</button>
)}
</>
@@ -333,19 +392,19 @@ export function LightboxDetailsPanel({
<form
className="mt-2 flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
const raw = tagInput.trim();
if (!raw || tagAdding) return;
setTagAdding(true);
const taggedImageId = selectedImage.id;
event.preventDefault()
const raw = tagInput.trim()
if (!raw || tagAdding) return
setTagAdding(true)
const taggedImageId = selectedImage.id
void addUserTag(taggedImageId, raw)
.then((newTag) => {
if (currentImageIdRef.current !== taggedImageId) return;
setImageTags((prev) => [...prev, newTag]);
setTagInput("");
if (currentImageIdRef.current !== taggedImageId) return
setImageTags((prev) => [...prev, newTag])
setTagInput('')
})
.catch(() => undefined)
.finally(() => setTagAdding(false));
.finally(() => setTagAdding(false))
}}
>
<input
@@ -374,15 +433,18 @@ export function LightboxDetailsPanel({
{hasCameraInfo ? (
<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">
{imageExif.make || imageExif.model ? (
<p className="text-sm text-white">
{[imageExif.make, imageExif.model].filter(Boolean).join(" ")}
{[imageExif.make, imageExif.model].filter(Boolean).join(' ')}
</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">
{imageExif.f_number ? <span>{imageExif.f_number}</span> : null}
{imageExif.exposure_time ? <span>{imageExif.exposure_time}</span> : null}
@@ -395,14 +457,19 @@ export function LightboxDetailsPanel({
<button
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
onClick={() =>
void invoke("open_map_location", {
void invoke('open_map_location', {
params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon },
})
}
>
{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">
<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>
</button>
</Tooltip>
@@ -413,25 +480,30 @@ export function LightboxDetailsPanel({
<div>
<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>
<button
className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300"
onClick={() => void revealItemInDir(selectedImage.path)}
>
<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>
</button>
</Tooltip>
</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 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}
</div>
</div>
);
)
}
+10 -10
View File
@@ -1,25 +1,25 @@
import { ChevronRightIcon } from "../icons";
import { ChevronRightIcon } from '../icons'
interface LightboxNavButtonProps {
direction: "previous" | "next";
disabled: boolean;
onClick: () => void;
direction: 'previous' | 'next'
disabled: boolean
onClick: () => void
}
export function LightboxNavButton({ direction, disabled, onClick }: LightboxNavButtonProps) {
const isNext = direction === "next";
const isNext = direction === 'next'
return (
<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 ${
isNext ? "right-72 lg:right-80" : "left-4"
isNext ? 'right-72 lg:right-80' : 'left-4'
}`}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
event.stopPropagation()
onClick()
}}
aria-label={isNext ? "Next image" : "Previous image"}
aria-label={isNext ? 'Next image' : 'Previous image'}
>
{isNext ? (
<ChevronRightIcon className="h-5 w-5" />
@@ -29,5 +29,5 @@ export function LightboxNavButton({ direction, disabled, onClick }: LightboxNavB
</svg>
)}
</button>
);
)
}
+87 -51
View File
@@ -1,28 +1,28 @@
import { AnimatePresence, motion } from "framer-motion";
import { RefObject } from "react";
import { ImageRecord } from "../../store";
import { mediaSrc } from "../../lib/mediaSrc";
import { VideoPlayer } from "../VideoPlayer";
import { Tooltip } from "../Tooltip";
import { SelectionOverlay, ViewTransform } from "./types";
import { MAX_ZOOM, MIN_ZOOM, zoomViewAt } from "./viewTransform";
import { AnimatePresence, motion } from 'framer-motion'
import { RefObject } from 'react'
import { ImageRecord } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { VideoPlayer } from '../VideoPlayer'
import { Tooltip } from '../Tooltip'
import { SelectionOverlay, ViewTransform } from './types'
import { MAX_ZOOM, MIN_ZOOM, zoomViewAt } from './viewTransform'
interface LightboxViewportProps {
selectedImage: ImageRecord;
imageViewportRef: RefObject<HTMLDivElement | null>;
imgRef: RefObject<HTMLImageElement | null>;
view: ViewTransform;
zoom: number;
regionSelectMode: boolean;
isPanning: boolean;
selectionOverlay: SelectionOverlay | null;
canStartSlideshow: boolean;
onStartSlideshow: () => void;
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
setView: React.Dispatch<React.SetStateAction<ViewTransform>>;
clampPan: (view: ViewTransform) => ViewTransform;
selectedImage: ImageRecord
imageViewportRef: RefObject<HTMLDivElement | null>
imgRef: RefObject<HTMLImageElement | null>
view: ViewTransform
zoom: number
regionSelectMode: boolean
isPanning: boolean
selectionOverlay: SelectionOverlay | null
canStartSlideshow: boolean
onStartSlideshow: () => void
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void
setView: React.Dispatch<React.SetStateAction<ViewTransform>>
clampPan: (view: ViewTransform) => ViewTransform
}
export function LightboxViewport({
@@ -47,12 +47,12 @@ export function LightboxViewport({
ref={imageViewportRef}
className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${
regionSelectMode
? "cursor-crosshair select-none"
? 'cursor-crosshair select-none'
: isPanning
? "cursor-grabbing select-none"
: zoom > 1 && selectedImage.media_kind === "image"
? "cursor-grab"
: ""
? 'cursor-grabbing select-none'
: zoom > 1 && selectedImage.media_kind === 'image'
? 'cursor-grab'
: ''
}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
@@ -61,10 +61,24 @@ export function LightboxViewport({
{regionSelectMode && (
<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">
<svg 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
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>
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>
)}
@@ -85,28 +99,28 @@ export function LightboxViewport({
<motion.div
key={selectedImage.id}
className={
selectedImage.media_kind === "video"
? "absolute inset-0"
: "flex items-center justify-center"
selectedImage.media_kind === 'video'
? 'absolute inset-0'
: 'flex items-center justify-center'
}
initial={{ opacity: 0.3, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0.3, scale: 0.985 }}
transition={{ duration: 0.12 }}
>
{selectedImage.media_kind === "video" ? (
<VideoPlayer src={mediaSrc(selectedImage.path) ?? ""} />
{selectedImage.media_kind === 'video' ? (
<VideoPlayer src={mediaSrc(selectedImage.path) ?? ''} />
) : (
<img
ref={imgRef}
src={mediaSrc(selectedImage.path) ?? ""}
src={mediaSrc(selectedImage.path) ?? ''}
alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl"
draggable={false}
style={{
maxHeight: "calc(100vh - 10rem)",
maxHeight: 'calc(100vh - 10rem)',
transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`,
transformOrigin: "center center",
transformOrigin: 'center center',
...(regionSelectMode ? { opacity: 0.85 } : {}),
}}
/>
@@ -115,42 +129,64 @@ export function LightboxViewport({
</AnimatePresence>
{!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">
<Tooltip label={canStartSlideshow ? "Start slideshow" : "No images available for slideshow"} followCursor>
<Tooltip
label={canStartSlideshow ? 'Start slideshow' : 'No images available for slideshow'}
followCursor
>
<button
aria-label="Start slideshow"
className={`rounded-full p-2 transition-colors ${
canStartSlideshow
? "text-gray-300 hover:bg-white/10 hover:text-white"
: "cursor-not-allowed text-gray-600"
? 'text-gray-300 hover:bg-white/10 hover:text-white'
: 'cursor-not-allowed text-gray-600'
}`}
disabled={!canStartSlideshow}
onClick={(event) => {
event.stopPropagation();
onStartSlideshow();
event.stopPropagation()
onStartSlideshow()
}}
>
<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>
</button>
</Tooltip>
{selectedImage.media_kind === "image" ? (
{selectedImage.media_kind === 'image' ? (
<>
<div className="mx-0.5 h-5 w-px bg-white/10" />
<button
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"
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>
<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
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"
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>
@@ -160,5 +196,5 @@ export function LightboxViewport({
</div>
)}
</div>
);
)
}
+46 -39
View File
@@ -1,23 +1,23 @@
import { AnimatePresence, motion } from "framer-motion";
import { ImageRecord } from "../../store";
import { mediaSrc } from "../../lib/mediaSrc";
import { Tooltip } from "../Tooltip";
import { ChevronRightIcon, CloseIcon } from "../icons";
import { SlideshowMotion } from "./useSlideshow";
import { AnimatePresence, motion } from 'framer-motion'
import { ImageRecord } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { Tooltip } from '../Tooltip'
import { ChevronRightIcon, CloseIcon } from '../icons'
import { SlideshowMotion } from './useSlideshow'
interface SlideshowViewProps {
selectedImage: ImageRecord;
imageCount: number;
position: number;
controlsShown: boolean;
paused: boolean;
loadingMore: boolean;
motionConfig: SlideshowMotion;
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
onShowControls: () => void;
onExit: () => void;
onGo: (direction: -1 | 1) => void;
onTogglePaused: () => void;
selectedImage: ImageRecord
imageCount: number
position: number
controlsShown: boolean
paused: boolean
loadingMore: boolean
motionConfig: SlideshowMotion
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void
onShowControls: () => void
onExit: () => void
onGo: (direction: -1 | 1) => void
onTogglePaused: () => void
}
export function SlideshowView({
@@ -37,16 +37,16 @@ export function SlideshowView({
return (
<div
className={`relative flex h-full w-full items-center justify-center overflow-hidden bg-black ${
controlsShown ? "" : "cursor-none"
controlsShown ? '' : 'cursor-none'
}`}
onClick={(event) => {
event.stopPropagation();
onShowControls();
event.stopPropagation()
onShowControls()
}}
onPointerMove={onPointerMove}
>
<AnimatePresence initial={false}>
{selectedImage.media_kind === "image" ? (
{selectedImage.media_kind === 'image' ? (
<motion.div
key={selectedImage.id}
className="absolute inset-0 flex items-center justify-center"
@@ -56,7 +56,7 @@ export function SlideshowView({
transition={motionConfig.imageTransition}
>
<motion.img
src={mediaSrc(selectedImage.path) ?? ""}
src={mediaSrc(selectedImage.path) ?? ''}
alt={selectedImage.filename}
className="max-h-full max-w-full object-contain"
draggable={false}
@@ -84,20 +84,22 @@ export function SlideshowView({
animate={{ opacity: controlsShown ? 1 : 0, y: controlsShown ? 0 : -6 }}
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="mx-1 text-gray-600">/</span>
<span>{imageCount}</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>
<Tooltip label="Exit slideshow" followCursor>
<button
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"
onClick={(event) => {
event.stopPropagation();
onExit();
event.stopPropagation()
onExit()
}}
>
<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"
disabled={imageCount <= 1}
onClick={(event) => {
event.stopPropagation();
onGo(-1);
event.stopPropagation()
onGo(-1)
}}
>
<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>
</button>
</Tooltip>
<Tooltip label={paused ? "Resume slideshow" : "Pause slideshow"} followCursor>
<Tooltip label={paused ? 'Resume slideshow' : 'Pause slideshow'} followCursor>
<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"
onClick={(event) => {
event.stopPropagation();
onTogglePaused();
onShowControls();
event.stopPropagation()
onTogglePaused()
onShowControls()
}}
>
{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"
disabled={imageCount <= 1}
onClick={(event) => {
event.stopPropagation();
onGo(1);
event.stopPropagation()
onGo(1)
}}
>
<ChevronRightIcon className="h-5 w-5" strokeWidth={1.8} />
@@ -165,10 +172,10 @@ export function SlideshowView({
</motion.div>
{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
</div>
) : null}
</div>
);
)
}
+37 -31
View File
@@ -1,56 +1,62 @@
import { AiRating } from "../../store";
import { AiRating } from '../../store'
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function formatDate(iso: string | null): string {
if (!iso) return "Unknown";
if (!iso) return 'Unknown'
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatDuration(durationMs: number | null): string {
if (!durationMs || durationMs <= 0) return "Pending / unavailable";
const totalSeconds = Math.floor(durationMs / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (!durationMs || durationMs <= 0) return 'Pending / unavailable'
const totalSeconds = Math.floor(durationMs / 1000)
const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60) % 60
const hours = Math.floor(totalSeconds / 3600)
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 {
if (status === "ready") {
return model ? `Ready (${model})` : "Ready";
if (status === 'ready') {
return model ? `Ready (${model})` : 'Ready'
}
if (status === "failed") {
return "Failed";
if (status === 'failed') {
return 'Failed'
}
if (status === "processing") {
return "Processing";
if (status === 'processing') {
return 'Processing'
}
return "Queued";
return 'Queued'
}
export function ratingPill(rating: AiRating): { label: string; className: string } {
switch (rating) {
case "general":
return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" };
case "sensitive":
return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-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" };
case 'general':
return {
label: 'General',
className: 'border-emerald-400/25 bg-emerald-500/10 text-emerald-300',
}
case 'sensitive':
return { label: 'Sensitive', className: 'border-sky-400/25 bg-sky-500/10 text-sky-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 {
startX: number;
startY: number;
endX: number;
endY: number;
startX: number
startY: number
endX: number
endY: number
}
export interface ViewTransform {
zoom: number;
panX: number;
panY: number;
zoom: number
panX: number
panY: number
}
export interface NormalizedCrop {
x: number;
y: number;
w: number;
h: number;
x: number
y: number
w: number
h: number
}
export interface SelectionOverlay {
left: number;
top: number;
width: number;
height: number;
left: number
top: number
width: number
height: number
}
@@ -1,13 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { ImageExif, ImageRecord, ImageTag, TaggerModelStatus } from "../../store";
import { useEffect, useRef, useState } from 'react'
import { ImageExif, ImageRecord, ImageTag, TaggerModelStatus } from '../../store'
interface UseLightboxMediaDetailsParams {
selectedImage: ImageRecord | null;
taggerModelStatus: TaggerModelStatus | null;
getImageTags: (imageId: number) => Promise<ImageTag[]>;
getImageExif: (imageId: number) => Promise<ImageExif>;
loadTaggerModelStatus: () => Promise<void>;
onSelectedImageReset: () => void;
selectedImage: ImageRecord | null
taggerModelStatus: TaggerModelStatus | null
getImageTags: (imageId: number) => Promise<ImageTag[]>
getImageExif: (imageId: number) => Promise<ImageExif>
loadTaggerModelStatus: () => Promise<void>
onSelectedImageReset: () => void
}
export function useLightboxMediaDetails({
@@ -18,54 +18,66 @@ export function useLightboxMediaDetails({
loadTaggerModelStatus,
onSelectedImageReset,
}: UseLightboxMediaDetailsParams) {
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
const [imageExif, setImageExif] = useState<ImageExif | null>(null);
const [tagInput, setTagInput] = useState("");
const [tagAdding, setTagAdding] = useState(false);
const [tagsExpanded, setTagsExpanded] = useState(false);
const [taggingQueued, setTaggingQueued] = useState(false);
const [imageTags, setImageTags] = useState<ImageTag[]>([])
const [imageExif, setImageExif] = useState<ImageExif | null>(null)
const [tagInput, setTagInput] = useState('')
const [tagAdding, setTagAdding] = useState(false)
const [tagsExpanded, setTagsExpanded] = useState(false)
const [taggingQueued, setTaggingQueued] = useState(false)
const currentImageIdRef = useRef<number | null>(null);
currentImageIdRef.current = selectedImage?.id ?? null;
const currentImageIdRef = useRef<number | null>(null)
currentImageIdRef.current = selectedImage?.id ?? null
useEffect(() => {
setImageTags([]);
setImageExif(null);
setTagInput("");
setTagsExpanded(false);
setTaggingQueued(false);
onSelectedImageReset();
}, [onSelectedImageReset, selectedImage?.id]);
setImageTags([])
setImageExif(null)
setTagInput('')
setTagsExpanded(false)
setTaggingQueued(false)
onSelectedImageReset()
}, [onSelectedImageReset, selectedImage?.id])
useEffect(() => {
if (!selectedImage) return;
let cancelled = false;
if (!selectedImage) return
let cancelled = false
void getImageTags(selectedImage.id)
.then((tags) => { if (!cancelled) setImageTags(tags); })
.catch(() => { if (!cancelled) setImageTags([]); });
return () => { cancelled = true; };
}, [getImageTags, selectedImage?.ai_tagged_at, selectedImage?.id]);
useEffect(() => {
if (!selectedImage || selectedImage.media_kind !== "image") {
setImageExif(null);
return;
.then((tags) => {
if (!cancelled) setImageTags(tags)
})
.catch(() => {
if (!cancelled) setImageTags([])
})
return () => {
cancelled = true
}
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)
.then((exif) => { if (!cancelled) setImageExif(exif); })
.catch(() => { if (!cancelled) setImageExif(null); });
return () => { cancelled = true; };
}, [getImageExif, selectedImage?.id, selectedImage?.media_kind]);
.then((exif) => {
if (!cancelled) setImageExif(exif)
})
.catch(() => {
if (!cancelled) setImageExif(null)
})
return () => {
cancelled = true
}
}, [getImageExif, selectedImage?.id, selectedImage?.media_kind])
useEffect(() => {
if (selectedImage?.media_kind !== "image" || taggerModelStatus !== null) return;
void loadTaggerModelStatus();
}, [loadTaggerModelStatus, selectedImage?.media_kind, taggerModelStatus]);
if (selectedImage?.media_kind !== 'image' || taggerModelStatus !== null) return
void loadTaggerModelStatus()
}, [loadTaggerModelStatus, selectedImage?.media_kind, taggerModelStatus])
useEffect(() => {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
}, [selectedImage?.ai_tagged_at]);
if (selectedImage?.ai_tagged_at) setTaggingQueued(false)
}, [selectedImage?.ai_tagged_at])
return {
currentImageIdRef,
@@ -80,5 +92,5 @@ export function useLightboxMediaDetails({
setTagsExpanded,
taggingQueued,
setTaggingQueued,
};
}
}
@@ -1,23 +1,23 @@
import { useCallback, useEffect } from "react";
import { ImageRecord } from "../../store";
import { ViewTransform } from "./types";
import { zoomViewAt } from "./viewTransform";
import { useCallback, useEffect } from 'react'
import { ImageRecord } from '../../store'
import { ViewTransform } from './types'
import { zoomViewAt } from './viewTransform'
interface UseLightboxNavigationParams {
selectedImage: ImageRecord | null;
images: ImageRecord[];
currentIndex: number;
slideshowActive: boolean;
regionSelectMode: boolean;
closeImage: () => void;
exitRegionMode: () => void;
exitSlideshow: () => void;
goSlideshow: (direction: -1 | 1) => void;
showSlideshowControls: () => void;
setSlideshowPaused: React.Dispatch<React.SetStateAction<boolean>>;
openImage: (image: ImageRecord) => void;
setView: React.Dispatch<React.SetStateAction<ViewTransform>>;
clampPan: (view: ViewTransform) => ViewTransform;
selectedImage: ImageRecord | null
images: ImageRecord[]
currentIndex: number
slideshowActive: boolean
regionSelectMode: boolean
closeImage: () => void
exitRegionMode: () => void
exitSlideshow: () => void
goSlideshow: (direction: -1 | 1) => void
showSlideshowControls: () => void
setSlideshowPaused: React.Dispatch<React.SetStateAction<boolean>>
openImage: (image: ImageRecord) => void
setView: React.Dispatch<React.SetStateAction<ViewTransform>>
clampPan: (view: ViewTransform) => ViewTransform
}
export function useLightboxNavigation({
@@ -37,58 +37,58 @@ export function useLightboxNavigation({
clampPan,
}: UseLightboxNavigationParams) {
const goPrev = useCallback(() => {
if (currentIndex > 0) openImage(images[currentIndex - 1]);
}, [currentIndex, images, openImage]);
if (currentIndex > 0) openImage(images[currentIndex - 1])
}, [currentIndex, images, openImage])
const goNext = useCallback(() => {
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]);
}, [currentIndex, images, openImage]);
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1])
}, [currentIndex, images, openImage])
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (!selectedImage) return;
if (!selectedImage) return
if (slideshowActive) {
if (event.key === "Escape") {
event.preventDefault();
exitSlideshow();
return;
if (event.key === 'Escape') {
event.preventDefault()
exitSlideshow()
return
}
if (event.key === " ") {
event.preventDefault();
setSlideshowPaused((paused) => !paused);
showSlideshowControls();
return;
if (event.key === ' ') {
event.preventDefault()
setSlideshowPaused((paused) => !paused)
showSlideshowControls()
return
}
if (event.key === "ArrowLeft" && !event.shiftKey) {
event.preventDefault();
goSlideshow(-1);
return;
if (event.key === 'ArrowLeft' && !event.shiftKey) {
event.preventDefault()
goSlideshow(-1)
return
}
if (event.key === "ArrowRight" && !event.shiftKey) {
event.preventDefault();
goSlideshow(1);
return;
if (event.key === 'ArrowRight' && !event.shiftKey) {
event.preventDefault()
goSlideshow(1)
return
}
return;
return
}
if (event.key === "Escape") {
if (event.key === 'Escape') {
if (regionSelectMode) {
exitRegionMode();
exitRegionMode()
} else {
closeImage();
closeImage()
}
}
if (regionSelectMode) return;
if (event.key === "ArrowLeft" && !event.shiftKey) goPrev();
if (event.key === "ArrowRight" && !event.shiftKey) goNext();
if (event.key === "+" || event.key === "=")
setView((view) => clampPan(zoomViewAt(view, Math.min(3, view.zoom + 0.25), 0, 0)));
if (event.key === "-")
setView((view) => clampPan(zoomViewAt(view, Math.max(0.75, view.zoom - 0.25), 0, 0)));
};
if (regionSelectMode) return
if (event.key === 'ArrowLeft' && !event.shiftKey) goPrev()
if (event.key === 'ArrowRight' && !event.shiftKey) goNext()
if (event.key === '+' || event.key === '=')
setView((view) => clampPan(zoomViewAt(view, Math.min(3, view.zoom + 0.25), 0, 0)))
if (event.key === '-')
setView((view) => clampPan(zoomViewAt(view, Math.max(0.75, view.zoom - 0.25), 0, 0)))
}
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [
clampPan,
closeImage,
@@ -103,7 +103,7 @@ export function useLightboxNavigation({
setView,
showSlideshowControls,
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 { ImageRecord } from "../../store";
import { DragRect, ViewTransform } from "./types";
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import { ImageRecord } from '../../store'
import { DragRect, ViewTransform } from './types'
import {
clampPanToViewport,
MIN_SELECTION_FRACTION,
normaliseRect,
rectToNormalisedCrop,
zoomViewAt,
} from "./viewTransform";
} from './viewTransform'
interface UseRegionSelectionParams {
selectedImage: ImageRecord | null;
slideshowActive: boolean;
imageViewportRef: RefObject<HTMLDivElement | null>;
imgRef: RefObject<HTMLImageElement | null>;
view: ViewTransform;
setView: Dispatch<SetStateAction<ViewTransform>>;
selectedImage: ImageRecord | null
slideshowActive: boolean
imageViewportRef: RefObject<HTMLDivElement | null>
imgRef: RefObject<HTMLImageElement | null>
view: ViewTransform
setView: Dispatch<SetStateAction<ViewTransform>>
findSimilarByRegion: (
imageId: number,
crop: { x: number; y: number; w: number; h: number },
sourceFolderId: number | null,
) => Promise<void>;
sourceFolderId: number | null
) => Promise<void>
}
export function useRegionSelection({
@@ -32,126 +40,141 @@ export function useRegionSelection({
setView,
findSimilarByRegion,
}: UseRegionSelectionParams) {
const [regionSelectMode, setRegionSelectMode] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragRect, setDragRect] = useState<DragRect | null>(null);
const [regionSearching, setRegionSearching] = useState(false);
const lastPanPointRef = useRef({ x: 0, y: 0 });
const [regionSelectMode, setRegionSelectMode] = useState(false)
const [isPanning, setIsPanning] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [dragRect, setDragRect] = useState<DragRect | null>(null)
const [regionSearching, setRegionSearching] = useState(false)
const lastPanPointRef = useRef({ x: 0, y: 0 })
const clampPan = useCallback(
(nextView: ViewTransform): ViewTransform =>
clampPanToViewport(nextView, imgRef.current, imageViewportRef.current),
[imageViewportRef, imgRef],
);
[imageViewportRef, imgRef]
)
const exitRegionMode = useCallback(() => {
setRegionSelectMode(false);
setIsDragging(false);
setDragRect(null);
}, []);
setRegionSelectMode(false)
setIsDragging(false)
setDragRect(null)
}, [])
useEffect(() => {
const viewport = imageViewportRef.current;
if (!viewport || !selectedImage || selectedImage.media_kind !== "image" || slideshowActive) return;
const viewport = imageViewportRef.current
if (!viewport || !selectedImage || selectedImage.media_kind !== 'image' || slideshowActive)
return
const handleWheel = (event: WheelEvent) => {
if (regionSelectMode) return;
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
event.preventDefault();
const bounds = viewport.getBoundingClientRect();
const anchorX = event.clientX - (bounds.left + bounds.width / 2);
const anchorY = event.clientY - (bounds.top + bounds.height / 2);
if (regionSelectMode) return
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return
event.preventDefault()
const bounds = viewport.getBoundingClientRect()
const anchorX = event.clientX - (bounds.left + bounds.width / 2)
const anchorY = event.clientY - (bounds.top + bounds.height / 2)
setView((currentView) => {
const delta = event.deltaY < 0 ? 0.15 : -0.15;
const next = Math.min(4, Math.max(0.5, currentView.zoom + delta));
return clampPan(zoomViewAt(currentView, next, anchorX, anchorY));
});
};
const delta = event.deltaY < 0 ? 0.15 : -0.15
const next = Math.min(4, Math.max(0.5, currentView.zoom + delta))
return clampPan(zoomViewAt(currentView, next, anchorX, anchorY))
})
}
viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => viewport.removeEventListener("wheel", handleWheel);
}, [clampPan, imageViewportRef, regionSelectMode, selectedImage, setView, slideshowActive]);
viewport.addEventListener('wheel', handleWheel, { passive: false })
return () => viewport.removeEventListener('wheel', handleWheel)
}, [clampPan, imageViewportRef, regionSelectMode, selectedImage, setView, slideshowActive])
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!regionSelectMode) {
if (view.zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
setIsPanning(true);
if (view.zoom > 1 && event.button === 0 && selectedImage?.media_kind === 'image') {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
lastPanPointRef.current = { x: event.clientX, y: event.clientY }
setIsPanning(true)
}
return;
return
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
setIsDragging(true)
setDragRect({
startX: event.clientX,
startY: event.clientY,
endX: event.clientX,
endY: event.clientY,
});
})
},
[regionSelectMode, selectedImage?.media_kind, view.zoom],
);
[regionSelectMode, selectedImage?.media_kind, view.zoom]
)
const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
const dx = event.clientX - lastPanPointRef.current.x;
const dy = event.clientY - lastPanPointRef.current.y;
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
setView((currentView) => clampPan({ ...currentView, panX: currentView.panX + dx, panY: currentView.panY + dy }));
return;
const dx = event.clientX - lastPanPointRef.current.x
const dy = event.clientY - lastPanPointRef.current.y
lastPanPointRef.current = { x: event.clientX, y: event.clientY }
setView((currentView) =>
clampPan({ ...currentView, panX: currentView.panX + dx, panY: currentView.panY + dy })
)
return
}
if (!isDragging) return;
setDragRect((prev) =>
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
);
if (!isDragging) return
setDragRect((prev) => (prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null))
},
[clampPan, isDragging, isPanning, setView],
);
[clampPan, isDragging, isPanning, setView]
)
const handlePointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
event.currentTarget.releasePointerCapture(event.pointerId);
setIsPanning(false);
return;
event.currentTarget.releasePointerCapture(event.pointerId)
setIsPanning(false)
return
}
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
setIsDragging(false);
return;
setIsDragging(false)
return
}
event.currentTarget.releasePointerCapture(event.pointerId);
event.currentTarget.releasePointerCapture(event.pointerId)
const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY };
const crop = rectToNormalisedCrop(finalRect, imgRef.current);
const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY }
const crop = rectToNormalisedCrop(finalRect, imgRef.current)
setIsDragging(false);
setDragRect(null);
setIsDragging(false)
setDragRect(null)
const containerBounds = imageViewportRef.current?.getBoundingClientRect();
const containerBounds = imageViewportRef.current?.getBoundingClientRect()
const containerSize = containerBounds
? Math.min(containerBounds.width, containerBounds.height)
: 500;
const selW = Math.abs(finalRect.endX - finalRect.startX);
const selH = Math.abs(finalRect.endY - finalRect.startY);
if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) {
exitRegionMode();
return;
: 500
const selW = Math.abs(finalRect.endX - finalRect.startX)
const selH = Math.abs(finalRect.endY - finalRect.startY)
if (
!crop ||
selW < containerSize * MIN_SELECTION_FRACTION ||
selH < containerSize * MIN_SELECTION_FRACTION
) {
exitRegionMode()
return
}
exitRegionMode();
setRegionSearching(true);
exitRegionMode()
setRegionSearching(true)
void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id)
.finally(() => setRegionSearching(false));
void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id).finally(() =>
setRegionSearching(false)
)
},
[dragRect, exitRegionMode, findSimilarByRegion, imageViewportRef, imgRef, isDragging, isPanning, selectedImage],
);
[
dragRect,
exitRegionMode,
findSimilarByRegion,
imageViewportRef,
imgRef,
isDragging,
isPanning,
selectedImage,
]
)
return {
regionSelectMode,
@@ -165,5 +188,5 @@ export function useRegionSelection({
handlePointerDown,
handlePointerMove,
handlePointerUp,
};
}
}
+153 -134
View File
@@ -1,37 +1,46 @@
import { useCallback, useEffect, useMemo, 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";
import {
useCallback,
useEffect,
useMemo,
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_CONTROLS_IDLE_MS = 5000;
const SLIDESHOW_LOAD_MORE_THRESHOLD = 3;
const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]
const SLIDESHOW_CONTROLS_IDLE_MS = 5000
const SLIDESHOW_LOAD_MORE_THRESHOLD = 3
export interface SlideshowMotion {
imageInitial: { opacity: number; filter: string };
imageAnimate: { opacity: number; filter: string };
imageExit: { opacity: number; filter: string };
imageTransition: Transition;
contentInitial: { scale: number; x?: number; y?: number };
contentAnimate: { scale: number; x?: number; y?: number };
contentTransition: Transition;
imageInitial: { opacity: number; filter: string }
imageAnimate: { opacity: number; filter: string }
imageExit: { opacity: number; filter: string }
imageTransition: Transition
contentInitial: { scale: number; x?: number; y?: number }
contentAnimate: { scale: number; x?: number; y?: number }
contentTransition: Transition
}
interface UseSlideshowParams {
rootRef: RefObject<HTMLDivElement | null>;
selectedImage: ImageRecord | null;
images: ImageRecord[];
currentIndex: number;
loadedCount: number;
totalImages: number;
intervalSeconds: number;
order: SlideshowOrder;
transition: SlideshowTransition;
openImage: (image: ImageRecord) => void;
loadMoreImages: () => Promise<void>;
exitRegionMode: () => void;
setView: Dispatch<SetStateAction<ViewTransform>>;
rootRef: RefObject<HTMLDivElement | null>
selectedImage: ImageRecord | null
images: ImageRecord[]
currentIndex: number
loadedCount: number
totalImages: number
intervalSeconds: number
order: SlideshowOrder
transition: SlideshowTransition
openImage: (image: ImageRecord) => void
loadMoreImages: () => Promise<void>
exitRegionMode: () => void
setView: Dispatch<SetStateAction<ViewTransform>>
}
export function useSlideshow({
@@ -49,190 +58,200 @@ export function useSlideshow({
exitRegionMode,
setView,
}: UseSlideshowParams) {
const [active, setActive] = useState(false);
const [paused, setPaused] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [motionStep, setMotionStep] = useState(0);
const [active, setActive] = useState(false)
const [paused, setPaused] = useState(false)
const [controlsVisible, setControlsVisible] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [motionStep, setMotionStep] = useState(0)
const controlsIdleTimeoutRef = useRef<number | null>(null);
const pointerRef = useRef<{ x: number; y: number } | null>(null);
const randomSeenRef = useRef<Set<number>>(new Set());
const controlsIdleTimeoutRef = useRef<number | null>(null)
const pointerRef = useRef<{ x: number; y: number } | null>(null)
const randomSeenRef = useRef<Set<number>>(new Set())
const slideshowImages = useMemo(
() => images.filter((image) => image.media_kind === "image"),
[images],
);
() => images.filter((image) => image.media_kind === 'image'),
[images]
)
const slideshowIndex = selectedImage
? slideshowImages.findIndex((image) => image.id === selectedImage.id)
: -1;
const position = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1);
const canStart = slideshowImages.length > 0;
: -1
const position = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1)
const canStart = slideshowImages.length > 0
const directions = [
{ x: -1, y: 0.45 },
{ x: 1, y: -0.45 },
{ 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 = {
imageInitial: { opacity: 0, filter: "blur(8px)" },
imageAnimate: { opacity: 1, filter: "blur(0px)" },
imageExit: { opacity: 0, filter: "blur(4px)" },
imageInitial: { opacity: 0, filter: 'blur(8px)' },
imageAnimate: { opacity: 1, filter: 'blur(0px)' },
imageExit: { opacity: 0, filter: 'blur(4px)' },
imageTransition: { duration: 0.72, ease: SLIDESHOW_EASE },
contentInitial:
transition === "gentle-motion"
transition === 'gentle-motion'
? { scale: 1.012, x: -8 * direction.x, y: 8 * direction.y }
: { scale: 1.012 },
contentAnimate:
transition === "gentle-motion"
transition === 'gentle-motion'
? { scale: 1.026, x: 8 * direction.x, y: -8 * direction.y }
: { scale: 1 },
contentTransition:
transition === "gentle-motion"
? { duration: intervalSeconds + 0.75, ease: "linear" }
transition === 'gentle-motion'
? { duration: intervalSeconds + 0.75, ease: 'linear' }
: { duration: 0.72, ease: SLIDESHOW_EASE },
};
}
const showControls = useCallback(() => {
if (controlsIdleTimeoutRef.current !== null) {
window.clearTimeout(controlsIdleTimeoutRef.current);
controlsIdleTimeoutRef.current = null;
window.clearTimeout(controlsIdleTimeoutRef.current)
controlsIdleTimeoutRef.current = null
}
setControlsVisible(true);
setControlsVisible(true)
if (active) {
controlsIdleTimeoutRef.current = window.setTimeout(() => {
setControlsVisible(false);
controlsIdleTimeoutRef.current = null;
}, SLIDESHOW_CONTROLS_IDLE_MS);
setControlsVisible(false)
controlsIdleTimeoutRef.current = null
}, SLIDESHOW_CONTROLS_IDLE_MS)
}
}, [active]);
}, [active])
const exit = useCallback(() => {
setActive(false);
setPaused(false);
setControlsVisible(true);
setActive(false)
setPaused(false)
setControlsVisible(true)
if (document.fullscreenElement === rootRef.current) {
void document.exitFullscreen().catch(() => undefined);
void document.exitFullscreen().catch(() => undefined)
}
}, [rootRef]);
}, [rootRef])
const go = useCallback(
(direction: -1 | 1, revealControls = true) => {
if (slideshowImages.length === 0) return;
if (slideshowImages.length === 0) return
if (slideshowImages.length === 1) {
openImage(slideshowImages[0]);
return;
openImage(slideshowImages[0])
return
}
const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0;
const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0
let nextIndex =
(currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length;
if (order === "random") {
const currentId = slideshowImages[currentSlideshowIndex]?.id;
(currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length
if (order === 'random') {
const currentId = slideshowImages[currentSlideshowIndex]?.id
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) {
randomSeenRef.current = new Set(currentId == null ? [] : [currentId]);
candidates = slideshowImages.filter((image) => image.id !== currentId);
randomSeenRef.current = new Set(currentId == null ? [] : [currentId])
candidates = slideshowImages.filter((image) => image.id !== currentId)
}
const nextImage = candidates[Math.floor(Math.random() * candidates.length)];
nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id);
randomSeenRef.current.add(nextImage.id);
const nextImage = candidates[Math.floor(Math.random() * candidates.length)]
nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id)
randomSeenRef.current.add(nextImage.id)
}
setMotionStep((step) => step + 1);
openImage(slideshowImages[nextIndex]);
setMotionStep((step) => step + 1)
openImage(slideshowImages[nextIndex])
if (revealControls) {
showControls();
showControls()
}
},
[openImage, order, showControls, slideshowImages, slideshowIndex],
);
[openImage, order, showControls, slideshowImages, slideshowIndex]
)
const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const previous = pointerRef.current;
const next = { x: event.clientX, y: event.clientY };
pointerRef.current = next;
const previous = pointerRef.current
const next = { x: event.clientX, y: event.clientY }
pointerRef.current = next
if (!previous) {
if (!controlsVisible) {
showControls();
showControls()
}
return;
return
}
if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) {
showControls();
showControls()
}
},
[controlsVisible, showControls],
);
[controlsVisible, showControls]
)
const start = useCallback(() => {
if (!selectedImage || slideshowImages.length === 0) return;
if (!selectedImage || slideshowImages.length === 0) return
const nextImage =
selectedImage.media_kind === "image"
selectedImage.media_kind === 'image'
? selectedImage
: images.slice(Math.max(0, currentIndex + 1)).find((image) => image.media_kind === "image") ??
slideshowImages[0];
: (images
.slice(Math.max(0, currentIndex + 1))
.find((image) => image.media_kind === 'image') ?? slideshowImages[0])
if (nextImage.id !== selectedImage.id) {
openImage(nextImage);
openImage(nextImage)
}
randomSeenRef.current = new Set([nextImage.id]);
setMotionStep(0);
exitRegionMode();
setView(IDENTITY_VIEW);
setPaused(false);
setControlsVisible(true);
pointerRef.current = null;
setActive(true);
void rootRef.current?.requestFullscreen().catch(() => undefined);
}, [currentIndex, exitRegionMode, images, openImage, rootRef, selectedImage, setView, slideshowImages]);
randomSeenRef.current = new Set([nextImage.id])
setMotionStep(0)
exitRegionMode()
setView(IDENTITY_VIEW)
setPaused(false)
setControlsVisible(true)
pointerRef.current = null
setActive(true)
void rootRef.current?.requestFullscreen().catch(() => undefined)
}, [
currentIndex,
exitRegionMode,
images,
openImage,
rootRef,
selectedImage,
setView,
slideshowImages,
])
useEffect(() => {
if (selectedImage) return;
setActive(false);
setPaused(false);
setControlsVisible(true);
}, [selectedImage]);
if (selectedImage) return
setActive(false)
setPaused(false)
setControlsVisible(true)
}, [selectedImage])
useEffect(() => {
const handleFullscreenChange = () => {
if (!active) return;
if (!active) return
if (document.fullscreenElement !== rootRef.current) {
setActive(false);
setPaused(false);
setControlsVisible(true);
setActive(false)
setPaused(false)
setControlsVisible(true)
}
}
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, [active, rootRef]);
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [active, rootRef])
useEffect(() => {
showControls();
showControls()
return () => {
if (controlsIdleTimeoutRef.current !== null) {
window.clearTimeout(controlsIdleTimeoutRef.current);
controlsIdleTimeoutRef.current = null;
window.clearTimeout(controlsIdleTimeoutRef.current)
controlsIdleTimeoutRef.current = null
}
};
}, [active, paused, showControls]);
}
}, [active, paused, showControls])
useEffect(() => {
if (!active || paused || slideshowImages.length <= 1) return;
if (!active || paused || slideshowImages.length <= 1) return
const timeout = window.setTimeout(() => {
go(1, false);
}, intervalSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [active, go, intervalSeconds, paused, selectedImage?.id, slideshowImages.length]);
go(1, false)
}, intervalSeconds * 1000)
return () => window.clearTimeout(timeout)
}, [active, go, intervalSeconds, paused, selectedImage?.id, slideshowImages.length])
useEffect(() => {
if (
@@ -242,11 +261,11 @@ export function useSlideshow({
slideshowImages.length === 0 ||
slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD
) {
return;
return
}
setLoadingMore(true);
void loadMoreImages().finally(() => setLoadingMore(false));
setLoadingMore(true)
void loadMoreImages().finally(() => setLoadingMore(false))
}, [
active,
loadedCount,
@@ -255,7 +274,7 @@ export function useSlideshow({
slideshowImages.length,
slideshowIndex,
totalImages,
]);
])
return {
active,
@@ -272,5 +291,5 @@ export function useSlideshow({
go,
showControls,
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_ZOOM = 0.5;
export const MAX_ZOOM = 4;
export const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 };
export const MIN_SELECTION_FRACTION = 0.02
export const MIN_ZOOM = 0.5
export const MAX_ZOOM = 4
export const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }
export function normaliseRect(r: DragRect): SelectionOverlay {
return {
@@ -11,57 +11,65 @@ export function normaliseRect(r: DragRect): SelectionOverlay {
top: Math.min(r.startY, r.endY),
width: Math.abs(r.endX - r.startX),
height: Math.abs(r.endY - r.startY),
};
}
}
export function rectToNormalisedCrop(rect: DragRect, imgEl: HTMLImageElement): NormalizedCrop | null {
const imgBounds = imgEl.getBoundingClientRect();
if (imgBounds.width === 0 || imgBounds.height === 0) return null;
export function rectToNormalisedCrop(
rect: DragRect,
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 rawY = Math.min(rect.startY, rect.endY);
const rawW = Math.abs(rect.endX - rect.startX);
const rawH = Math.abs(rect.endY - rect.startY);
const rawX = Math.min(rect.startX, rect.endX)
const rawY = Math.min(rect.startY, rect.endY)
const rawW = Math.abs(rect.endX - rect.startX)
const rawH = Math.abs(rect.endY - rect.startY)
const clampedX = Math.max(rawX, imgBounds.left);
const clampedY = Math.max(rawY, imgBounds.top);
const clampedRight = Math.min(rawX + rawW, imgBounds.right);
const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom);
const clampedX = Math.max(rawX, imgBounds.left)
const clampedY = Math.max(rawY, imgBounds.top)
const clampedRight = Math.min(rawX + rawW, imgBounds.right)
const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom)
const croppedW = clampedRight - clampedX;
const croppedH = clampedBottom - clampedY;
const croppedW = clampedRight - clampedX
const croppedH = clampedBottom - clampedY
if (croppedW <= 0 || croppedH <= 0) return null;
if (croppedW <= 0 || croppedH <= 0) return null
return {
x: (clampedX - imgBounds.left) / imgBounds.width,
y: (clampedY - imgBounds.top) / imgBounds.height,
w: croppedW / imgBounds.width,
h: croppedH / imgBounds.height,
};
}
}
export function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform {
if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom };
const ratio = newZoom / view.zoom;
export function zoomViewAt(
view: ViewTransform,
newZoom: number,
anchorX: number,
anchorY: number
): ViewTransform {
if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom }
const ratio = newZoom / view.zoom
return {
zoom: newZoom,
panX: anchorX - (anchorX - view.panX) * ratio,
panY: anchorY - (anchorY - view.panY) * ratio,
};
}
}
export function clampPanToViewport(
view: ViewTransform,
img: HTMLImageElement | null,
viewport: HTMLDivElement | null,
viewport: HTMLDivElement | null
): ViewTransform {
if (!img || !viewport) return view;
const maxX = Math.max(0, (img.offsetWidth * view.zoom - viewport.clientWidth) / 2);
const maxY = Math.max(0, (img.offsetHeight * view.zoom - viewport.clientHeight) / 2);
if (!img || !viewport) return view
const maxX = Math.max(0, (img.offsetWidth * view.zoom - viewport.clientWidth) / 2)
const maxY = Math.max(0, (img.offsetHeight * view.zoom - viewport.clientHeight) / 2)
return {
...view,
panX: Math.min(maxX, Math.max(-maxX, view.panX)),
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 { createPortal } from "react-dom";
import { MenuCloseContext, MenuPanel, MenuSize } from "./Menu";
import { useDismissable } from "./useDismissable";
import { ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MenuCloseContext, MenuPanel, MenuSize } from './Menu'
import { useDismissable } from './useDismissable'
/**
* Positioned floating menu. Renders through a portal so it is never caught
@@ -16,35 +16,35 @@ export function ContextMenu({
x,
y,
onClose,
size = "md",
align = "start",
size = 'md',
align = 'start',
className,
children,
}: {
x: number;
y: number;
onClose: () => void;
size?: MenuSize;
align?: "start" | "end";
className?: string;
children: ReactNode;
x: number
y: number
onClose: () => void
size?: MenuSize
align?: 'start' | 'end'
className?: string
children: ReactNode
}) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
useDismissable(ref, onClose);
useDismissable(ref, onClose)
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const margin = 8;
const rect = el.getBoundingClientRect();
const desiredLeft = align === "end" ? x - rect.width : x;
const el = ref.current
if (!el) return
const margin = 8
const rect = el.getBoundingClientRect()
const desiredLeft = align === 'end' ? x - rect.width : x
setPos({
left: Math.max(margin, Math.min(desiredLeft, window.innerWidth - rect.width - margin)),
top: Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin)),
});
}, [x, y, align]);
})
}, [x, y, align])
return createPortal(
<div
@@ -52,7 +52,7 @@ export function ContextMenu({
className="fixed z-50"
// Render hidden at the raw coordinates for the measuring pass; the
// 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()}
onContextMenu={(event) => event.preventDefault()}
>
@@ -62,6 +62,6 @@ export function ContextMenu({
</MenuPanel>
</MenuCloseContext.Provider>
</div>,
document.body,
);
document.body
)
}
+47 -45
View File
@@ -1,39 +1,39 @@
import { ReactNode, useRef, useState } from "react";
import { MenuCloseContext, MenuItem, MenuPanel, MenuSize } from "./Menu";
import { useDismissable } from "./useDismissable";
import { Tooltip } from "../Tooltip";
import { ChevronDownIcon } from "../icons";
import { ReactNode, useRef, useState } from 'react'
import { MenuCloseContext, MenuItem, MenuPanel, MenuSize } from './Menu'
import { useDismissable } from './useDismissable'
import { Tooltip } from '../Tooltip'
import { ChevronDownIcon } from '../icons'
export interface DropdownOption<T> {
value: T;
label: string;
value: T
label: string
/** Trailing detail on the option row (e.g. an item count). */
hint?: ReactNode;
hint?: ReactNode
}
const TRIGGER_STYLES = {
// Filled, bordered select — settings forms and panel headers.
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",
open: "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white",
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',
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).
ghost: {
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",
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',
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.
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",
open: "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white",
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',
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.
@@ -46,32 +46,32 @@ export function Dropdown<T extends string | number | null>({
options,
onChange,
ariaLabel,
align = "right",
trigger = "solid",
size = "sm",
align = 'right',
trigger = 'solid',
size = 'sm',
triggerIcon,
triggerTooltip,
triggerClassName = "",
panelClassName = "",
triggerClassName = '',
panelClassName = '',
}: {
value: T;
options: DropdownOption<T>[];
onChange: (value: T) => void;
ariaLabel: string;
align?: "left" | "right";
trigger?: keyof typeof TRIGGER_STYLES;
size?: MenuSize;
triggerIcon?: ReactNode;
triggerTooltip?: string;
triggerClassName?: string;
panelClassName?: string;
value: T
options: DropdownOption<T>[]
onChange: (value: T) => void
ariaLabel: string
align?: 'left' | 'right'
trigger?: keyof typeof TRIGGER_STYLES
size?: MenuSize
triggerIcon?: ReactNode
triggerTooltip?: string
triggerClassName?: string
panelClassName?: string
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((option) => Object.is(option.value, value)) ?? options[0];
const style = TRIGGER_STYLES[trigger];
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const current = options.find((option) => Object.is(option.value, value)) ?? options[0]
const style = TRIGGER_STYLES[trigger]
useDismissable(ref, () => setOpen(false), open);
useDismissable(ref, () => setOpen(false), open)
const button = (
<button
@@ -86,9 +86,11 @@ export function Dropdown<T extends string | number | null>({
>
{triggerIcon}
<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>
);
)
return (
<div ref={ref} className="relative">
@@ -103,12 +105,12 @@ export function Dropdown<T extends string | number | null>({
<div
role="listbox"
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)}>
<MenuPanel size={size} className={panelClassName}>
{options.map((option) => {
const selected = Object.is(option.value, value);
const selected = Object.is(option.value, value)
return (
<MenuItem
key={String(option.value)}
@@ -119,12 +121,12 @@ export function Dropdown<T extends string | number | null>({
checked={selected}
onSelect={() => onChange(option.value)}
/>
);
)
})}
</MenuPanel>
</MenuCloseContext.Provider>
</div>
) : null}
</div>
);
)
}
+89 -84
View File
@@ -6,34 +6,34 @@ import {
useLayoutEffect,
useRef,
useState,
} from "react";
import { CheckIcon, ChevronRightIcon } from "../icons";
} from 'react'
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. */
export const MenuCloseContext = createContext<() => void>(() => {});
const MenuSizeContext = createContext<MenuSize>("md");
export const MenuCloseContext = createContext<() => void>(() => {})
const MenuSizeContext = createContext<MenuSize>('md')
function itemClass(size: MenuSize, danger: boolean, disabled: boolean, active = false): string {
const base =
size === "sm"
? "w-full rounded-md px-3 py-1.5 text-[12px]"
: "w-full rounded-lg px-3 py-2 text-sm";
size === 'sm'
? 'w-full rounded-md px-3 py-1.5 text-[12px]'
: 'w-full rounded-lg px-3 py-2 text-sm'
const tone = disabled
? "text-gray-600 cursor-not-allowed"
? 'text-gray-600 cursor-not-allowed'
: 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
? "bg-white/[0.08] text-white"
: "text-gray-200 hover:bg-white/[0.06] hover:text-white";
? 'bg-white/[0.08] 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
// 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" {
return danger ? "danger" : disabled ? "disabled" : "default";
function itemTone(danger: boolean, disabled: boolean): 'danger' | 'disabled' | 'default' {
return danger ? 'danger' : disabled ? 'disabled' : 'default'
}
/**
@@ -42,32 +42,32 @@ function itemTone(danger: boolean, disabled: boolean): "danger" | "disabled" | "
*/
export function MenuPanel({
size,
className = "",
className = '',
children,
}: {
size?: MenuSize;
className?: string;
children: ReactNode;
size?: MenuSize
className?: string
children: ReactNode
}) {
const inherited = useContext(MenuSizeContext);
const resolved = size ?? inherited;
const inherited = useContext(MenuSizeContext)
const resolved = size ?? inherited
// Default min-width steps aside when the caller sets its own width class —
// Tailwind resolves competing min-w utilities by stylesheet order, not
// className order, so merging both would be unpredictable.
const widthClass = /(^|\s)(min-w-|w-)/.test(className)
? ""
: resolved === "sm"
? "min-w-40"
: "min-w-52";
? ''
: resolved === 'sm'
? 'min-w-40'
: 'min-w-52'
return (
<MenuSizeContext.Provider value={resolved}>
<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}
</div>
</MenuSizeContext.Provider>
);
)
}
export function MenuItem({
@@ -81,60 +81,63 @@ export function MenuItem({
keepOpen = false,
role,
}: {
label: ReactNode;
onSelect?: () => void;
danger?: boolean;
disabled?: boolean;
label: ReactNode
onSelect?: () => void
danger?: boolean
disabled?: boolean
/** 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). */
checked?: boolean;
hint?: ReactNode;
checked?: boolean
hint?: ReactNode
/** Skip the automatic menu close after selecting. */
keepOpen?: boolean;
role?: React.AriaRole;
keepOpen?: boolean
role?: React.AriaRole
}) {
const size = useContext(MenuSizeContext);
const close = useContext(MenuCloseContext);
const size = useContext(MenuSizeContext)
const close = useContext(MenuCloseContext)
return (
<button
type="button"
role={role}
aria-selected={role === "option" ? checked ?? active : undefined}
aria-selected={role === 'option' ? (checked ?? active) : undefined}
disabled={disabled}
data-tone={itemTone(danger, disabled)}
data-active={active || undefined}
className={`${itemClass(size, danger, disabled, active)} flex items-center justify-between gap-3`}
onClick={() => {
if (disabled) return;
onSelect?.();
if (!keepOpen) close();
if (disabled) return
onSelect?.()
if (!keepOpen) close()
}}
>
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{hint != null ? (
<span className={`shrink-0 ${size === "sm" ? "text-[10px]" : "text-xs"} text-gray-500`}>{hint}</span>
) : null}
{checked ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-blue-400" />
<span className={`shrink-0 ${size === 'sm' ? 'text-[10px]' : 'text-xs'} text-gray-500`}>
{hint}
</span>
) : null}
{checked ? <CheckIcon className="h-3.5 w-3.5 shrink-0 text-blue-400" /> : null}
</button>
);
)
}
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 }) {
return (
<div className="px-3 py-1 text-[10px] uppercase tracking-[0.18em] text-gray-600"
<div
className="px-3 py-1 text-[10px] tracking-[0.18em] text-gray-600 uppercase"
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
}}
>{children}</div>
);
>
{children}
</div>
)
}
/**
@@ -146,55 +149,55 @@ export function SubMenu({
label,
disabled = false,
children,
panelClassName = "",
panelClassName = '',
}: {
label: ReactNode;
disabled?: boolean;
children: ReactNode;
panelClassName?: string;
label: ReactNode
disabled?: boolean
children: ReactNode
panelClassName?: string
}) {
const size = useContext(MenuSizeContext);
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState({ flipX: false, shiftY: 0 });
const panelRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const size = useContext(MenuSizeContext)
const [open, setOpen] = useState(false)
const [placement, setPlacement] = useState({ flipX: false, shiftY: 0 })
const panelRef = useRef<HTMLDivElement>(null)
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
if (closeTimer.current) clearTimeout(closeTimer.current)
},
[],
);
[]
)
const openNow = () => {
if (disabled) return;
if (disabled) return
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
clearTimeout(closeTimer.current)
closeTimer.current = null
}
setOpen(true)
}
setOpen(true);
};
// Grace delay so the pointer can cross the small gap to the panel.
const closeSoon = () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => setOpen(false), 140);
};
if (closeTimer.current) clearTimeout(closeTimer.current)
closeTimer.current = setTimeout(() => setOpen(false), 140)
}
useLayoutEffect(() => {
if (!open) {
setPlacement({ flipX: false, shiftY: 0 });
return;
setPlacement({ flipX: false, shiftY: 0 })
return
}
const panel = panelRef.current;
if (!panel) return;
const margin = 8;
const rect = panel.getBoundingClientRect();
const overflowY = rect.bottom - (window.innerHeight - margin);
const panel = panelRef.current
if (!panel) return
const margin = 8
const rect = panel.getBoundingClientRect()
const overflowY = rect.bottom - (window.innerHeight - margin)
setPlacement({
flipX: rect.right > window.innerWidth - margin,
shiftY: overflowY > 0 ? -overflowY : 0,
});
}, [open]);
})
}, [open])
return (
<div className="relative" onPointerEnter={openNow} onPointerLeave={closeSoon}>
@@ -215,12 +218,14 @@ export function SubMenu({
{open ? (
<div
ref={panelRef}
className={`absolute top-0 -mt-1 z-10 ${placement.flipX ? "right-full mr-0.5" : "left-full ml-0.5"}`}
style={placement.shiftY !== 0 ? { transform: `translateY(${placement.shiftY}px)` } : undefined}
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
}
>
<MenuPanel className={panelClassName}>{children}</MenuPanel>
</div>
) : null}
</div>
);
)
}
+6 -6
View File
@@ -1,6 +1,6 @@
export { ContextMenu } from "./ContextMenu";
export { Dropdown } from "./Dropdown";
export type { DropdownOption } from "./Dropdown";
export { MenuCloseContext, MenuItem, MenuLabel, MenuPanel, MenuSeparator, SubMenu } from "./Menu";
export type { MenuSize } from "./Menu";
export { useDismissable } from "./useDismissable";
export { ContextMenu } from './ContextMenu'
export { Dropdown } from './Dropdown'
export type { DropdownOption } from './Dropdown'
export { MenuCloseContext, MenuItem, MenuLabel, MenuPanel, MenuSeparator, SubMenu } from './Menu'
export type { MenuSize } from './Menu'
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.
@@ -7,22 +7,22 @@ import { RefObject, useEffect } from "react";
export function useDismissable<T extends HTMLElement>(
ref: RefObject<T | null>,
onClose: () => void,
enabled = true,
enabled = true
) {
useEffect(() => {
if (!enabled) return;
if (!enabled) return
const handlePointerDown = (event: PointerEvent) => {
const el = ref.current;
if (el && !el.contains(event.target as Node)) onClose();
};
const el = ref.current
if (el && !el.contains(event.target as Node)) onClose()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
if (event.key === 'Escape') onClose()
}
window.addEventListener('pointerdown', handlePointerDown)
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [ref, onClose, enabled]);
window.removeEventListener('pointerdown', handlePointerDown)
window.removeEventListener('keydown', handleKeyDown)
}
}, [ref, onClose, enabled])
}
+49 -47
View File
@@ -1,54 +1,54 @@
import { useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../../store";
import { StepWelcome } from "./StepWelcome";
import { StepAddLibrary } from "./StepAddLibrary";
import { StepPipeline } from "./StepPipeline";
import { StepGalleryPreview } from "./StepGalleryPreview";
import { StepSearchDemo } from "./StepSearchDemo";
import { StepViews } from "./StepViews";
import { StepAiFeatures } from "./StepAiFeatures";
import { StepUpdates } from "./StepUpdates";
import { useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from '../../store'
import { StepWelcome } from './StepWelcome'
import { StepAddLibrary } from './StepAddLibrary'
import { StepPipeline } from './StepPipeline'
import { StepGalleryPreview } from './StepGalleryPreview'
import { StepSearchDemo } from './StepSearchDemo'
import { StepViews } from './StepViews'
import { StepAiFeatures } from './StepAiFeatures'
import { StepUpdates } from './StepUpdates'
const STEPS: { id: string; title: string; component: () => React.ReactNode }[] = [
{ id: "welcome", title: "Welcome", component: () => <StepWelcome /> },
{ id: "library", title: "Your library", component: () => <StepAddLibrary /> },
{ id: "pipeline", title: "Background work", component: () => <StepPipeline /> },
{ id: "gallery", title: "The gallery", component: () => <StepGalleryPreview /> },
{ id: "search", title: "Search", component: () => <StepSearchDemo /> },
{ id: "views", title: "Views", component: () => <StepViews /> },
{ id: "ai", title: "AI features", component: () => <StepAiFeatures /> },
{ id: "updates", title: "Staying current", component: () => <StepUpdates /> },
];
{ id: 'welcome', title: 'Welcome', component: () => <StepWelcome /> },
{ id: 'library', title: 'Your library', component: () => <StepAddLibrary /> },
{ id: 'pipeline', title: 'Background work', component: () => <StepPipeline /> },
{ id: 'gallery', title: 'The gallery', component: () => <StepGalleryPreview /> },
{ id: 'search', title: 'Search', component: () => <StepSearchDemo /> },
{ id: 'views', title: 'Views', component: () => <StepViews /> },
{ id: 'ai', title: 'AI features', component: () => <StepAiFeatures /> },
{ id: 'updates', title: 'Staying current', component: () => <StepUpdates /> },
]
export function OnboardingOverlay() {
const onboardingOpen = useGalleryStore((s) => s.onboardingOpen);
const onboardingStep = useGalleryStore((s) => s.onboardingStep);
const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep);
const completeOnboarding = useGalleryStore((s) => s.completeOnboarding);
const onboardingOpen = useGalleryStore((s) => s.onboardingOpen)
const onboardingStep = useGalleryStore((s) => s.onboardingStep)
const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep)
const completeOnboarding = useGalleryStore((s) => s.completeOnboarding)
useEffect(() => {
if (!onboardingOpen) return;
if (!onboardingOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") completeOnboarding();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onboardingOpen, completeOnboarding]);
if (event.key === 'Escape') completeOnboarding()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onboardingOpen, completeOnboarding])
if (!onboardingOpen) return null;
if (!onboardingOpen) return null
const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)];
const isFirst = onboardingStep === 0;
const isLast = onboardingStep >= STEPS.length - 1;
const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)]
const isFirst = onboardingStep === 0
const isLast = onboardingStep >= STEPS.length - 1
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="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:bg-black/40 fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm">
<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 */}
<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>
<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}
</p>
<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}
className={`h-1.5 rounded-full transition-all ${
i === onboardingStep
? "w-5 bg-white/70 light-theme:bg-gray-700"
? 'light-theme:bg-gray-700 w-5 bg-white/70'
: i < onboardingStep
? "w-1.5 bg-white/35 light-theme:bg-gray-400"
: "w-1.5 bg-white/15 light-theme:bg-gray-300"
? 'light-theme:bg-gray-400 w-1.5 bg-white/35'
: 'light-theme:bg-gray-300 w-1.5 bg-white/15'
}`}
onClick={() => setOnboardingStep(i)}
/>
@@ -87,16 +87,16 @@ export function OnboardingOverlay() {
</div>
{/* 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
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}
>
Skip tour
</button>
<div className="flex items-center gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 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)}
disabled={isFirst}
>
@@ -104,13 +104,15 @@ export function OnboardingOverlay() {
</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"
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
onClick={() =>
isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1)
}
>
{isLast ? "Finish" : "Next"}
{isLast ? 'Finish' : 'Next'}
</button>
</div>
</div>
</div>
</div>
);
)
}
+16 -13
View File
@@ -1,13 +1,13 @@
import { useGalleryStore } from "../../store";
import { FakeTile } from "./fakes";
import { useGalleryStore } from '../../store'
import { FakeTile } from './fakes'
export function StepAddLibrary() {
const folders = useGalleryStore((s) => s.folders);
const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen);
const folders = useGalleryStore((s) => s.folders)
const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen)
const handlePick = () => {
setFolderPickerOpen(true);
};
setFolderPickerOpen(true)
}
return (
<div>
@@ -16,15 +16,18 @@ export function StepAddLibrary() {
added, renamed, or removed. Nothing is moved or copied.
</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">
{folders.length > 0 ? (
<>
<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 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>
</>
) : (
@@ -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"
onClick={handlePick}
>
{folders.length > 0 ? "Add another folder" : "Choose a folder"}
{folders.length > 0 ? 'Add another folder' : 'Choose a folder'}
</button>
</div>
<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 thumbnails are generated:
As indexing runs, the gallery fills in roughly like this tiles appear immediately and
sharpen as thumbnails are generated:
</p>
<div className="media-dark-surface mt-3 grid grid-cols-6 gap-1.5">
<FakeTile index={0} />
@@ -57,5 +60,5 @@ export function StepAddLibrary() {
<FakeTile index={5} loaded={false} />
</div>
</div>
);
)
}
+88 -58
View File
@@ -1,62 +1,75 @@
import { useEffect } from "react";
import { TaggerModel, TaggerModelProgress, useGalleryStore } from "../../store";
import { TAGGER_MODELS } from "../../taggerModels";
import { FakeProgressBar, formatBytes } from "./fakes";
import { useEffect } from 'react'
import { TaggerModel, TaggerModelProgress, useGalleryStore } from '../../store'
import { TAGGER_MODELS } from '../../taggerModels'
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
// back to the coarse step count; null renders an indeterminate bar.
function taggerDownloadFraction(progress: TaggerModelProgress | null): number | null {
if (!progress) return null;
if (progress.downloaded_bytes != null && progress.total_bytes != null && progress.total_bytes > 0) {
return progress.downloaded_bytes / progress.total_bytes;
if (!progress) return null
if (
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;
return null;
if (progress.total_files > 0) return progress.completed_files / progress.total_files
return null
}
function TaggerModelChoice({ model, current, disabled, onSelect }: {
model: TaggerModel;
current: TaggerModel;
disabled: boolean;
onSelect: (model: TaggerModel) => void;
function TaggerModelChoice({
model,
current,
disabled,
onSelect,
}: {
model: TaggerModel
current: TaggerModel
disabled: boolean
onSelect: (model: TaggerModel) => void
}) {
const active = model === current;
const active = model === current
return (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
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"
: "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: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'
: '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}
onClick={() => onSelect(model)}
>
{TAGGER_MODELS[model].tab}
</button>
);
)
}
export function StepAiFeatures() {
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress);
const taggerModelError = useGalleryStore((s) => s.taggerModelError);
const taggerModel = useGalleryStore((s) => s.taggerModel);
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel);
const loadTaggerModel = useGalleryStore((s) => s.loadTaggerModel);
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus);
const setTaggerModel = useGalleryStore((s) => s.setTaggerModel);
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus)
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing)
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress)
const taggerModelError = useGalleryStore((s) => s.taggerModelError)
const taggerModel = useGalleryStore((s) => s.taggerModel)
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel)
const loadTaggerModel = useGalleryStore((s) => s.loadTaggerModel)
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus)
const setTaggerModel = useGalleryStore((s) => s.setTaggerModel)
useEffect(() => {
void loadTaggerModel();
void loadTaggerModelStatus();
}, [loadTaggerModel, loadTaggerModelStatus]);
void loadTaggerModel()
void loadTaggerModelStatus()
}, [loadTaggerModel, loadTaggerModelStatus])
const taggerReady = taggerModelStatus?.ready ?? false;
const taggerStatusLabel = taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed";
const taggerReady = taggerModelStatus?.ready ?? false
const taggerStatusLabel = taggerReady
? 'Installed'
: taggerModelPreparing
? 'Preparing'
: 'Not installed'
return (
<div>
@@ -65,26 +78,31 @@ export function StepAiFeatures() {
itself up automatically; AI tagging is optional and only downloads if you want it.
</p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">AI tagging optional</h4>
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
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="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Automatic tags for every image</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
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:
The AI tagger model labels images so you can search with{' '}
<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>
<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">
{(["wd", "joytag"] as const).map((model) => (
<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) => (
<TaggerModelChoice
key={model}
model={model}
current={taggerModel}
disabled={taggerModelPreparing}
onSelect={(nextModel) => {
if (nextModel === taggerModel) return;
void setTaggerModel(nextModel);
if (nextModel === taggerModel) return
void setTaggerModel(nextModel)
}}
/>
))}
@@ -97,7 +115,10 @@ export function StepAiFeatures() {
</p>
<span className="mt-2 flex flex-wrap gap-1.5">
{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}
</span>
))}
@@ -105,16 +126,16 @@ export function StepAiFeatures() {
</div>
<div className="shrink-0">
{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
</span>
) : (
<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()}
disabled={taggerModelPreparing}
>
{taggerModelPreparing ? "Downloading..." : "Download now"}
{taggerModelPreparing ? 'Downloading...' : 'Download now'}
</button>
)}
</div>
@@ -123,40 +144,49 @@ export function StepAiFeatures() {
<div className="mt-3">
<FakeProgressBar fraction={taggerDownloadFraction(taggerModelProgress)} />
<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>
{taggerModelProgress?.downloaded_bytes != null && taggerModelProgress.total_bytes != null ? (
<span className="truncate">
{taggerModelProgress?.current_file ?? 'Preparing...'}
</span>
{taggerModelProgress?.downloaded_bytes != null &&
taggerModelProgress.total_bytes != null ? (
<span className="shrink-0 tabular-nums">
{formatBytes(taggerModelProgress.downloaded_bytes)} / {formatBytes(taggerModelProgress.total_bytes)}
{formatBytes(taggerModelProgress.downloaded_bytes)} /{' '}
{formatBytes(taggerModelProgress.total_bytes)}
</span>
) : null}
</div>
</div>
) : null}
{!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}
</p>
) : null}
</div>
</div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Semantic search & similarity built in</h4>
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<h4 className="mt-6 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
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">
<p className="text-sm text-white">Search by meaning, find look-alikes</p>
<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,
"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.
Powers{' '}
<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>{' '}
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>
</div>
</div>
<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
can be downloaded any time from Settings.
Semantic search and similarity are part of the standard pipeline; AI tagging stays optional
and can be downloaded any time from Settings.
</p>
</div>
);
)
}
@@ -1,40 +1,41 @@
import { useEffect, useState } from "react";
import { FakeTile, ReplayButton } from "./fakes";
import { useEffect, useState } from 'react'
import { FakeTile, ReplayButton } from './fakes'
const REVEAL_MS = 280;
const REVEAL_MS = 280
// Two rows (cols-4) keeps the explainer text visible without scrolling.
const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = [
{},
{ favorite: true },
{},
{ duration: "1:24" },
{ duration: '1:24' },
{ rating: 5 },
{ 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
/// revealed with a replay control.
export function StepGalleryPreview() {
const [revealed, setRevealed] = useState(0);
const [revealed, setRevealed] = useState(0)
useEffect(() => {
if (revealed >= TILE_COUNT) return;
const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS);
return () => clearTimeout(timer);
}, [revealed]);
if (revealed >= TILE_COUNT) return
const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS)
return () => clearTimeout(timer)
}, [revealed])
const finished = revealed >= TILE_COUNT;
const finished = revealed >= TILE_COUNT
return (
<div>
<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
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>
<div className="media-dark-surface mt-5 grid grid-cols-4 gap-1.5">
@@ -44,18 +45,18 @@ export function StepGalleryPreview() {
</div>
<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">
<span className="text-gray-300">Click any tile</span> to open the lightbox keyboard navigation,
zoom, inline tag editing, ratings, and a full video player.
<span className="text-gray-300">Click any tile</span> to open the lightbox keyboard
navigation, zoom, inline tag editing, ratings, and a full video player.
</p>
<p className="pt-2.5">
<span className="text-gray-300">The toolbar</span> filters by type, favorites, or rating, sorts by
date/name/size, and switches grid density.
<span className="text-gray-300">The toolbar</span> filters by type, favorites, or
rating, sorts by date/name/size, and switches grid density.
</p>
</div>
{finished ? <ReplayButton onClick={() => setRevealed(0)} /> : null}
</div>
</div>
);
)
}
+44 -34
View File
@@ -1,47 +1,47 @@
import { useEffect, useState } from "react";
import { FakeProgressBar, FakeStageTag, ReplayButton } from "./fakes";
import { useEffect, useState } from 'react'
import { FakeProgressBar, FakeStageTag, ReplayButton } from './fakes'
const STAGES = ["Thumbnails", "Metadata", "Embeddings", "Tags"] as const;
const STAGE_TOTAL = 128;
const TICK_MS = 80;
const STEP_PER_TICK = 8;
const STAGES = ['Thumbnails', 'Metadata', 'Embeddings', 'Tags'] as const
const STAGE_TOTAL = 128
const TICK_MS = 80
const STEP_PER_TICK = 8
/// A one-shot fake of the background-tasks bar: each stage drains in order,
/// then it stops at "all done" with a replay control.
export function StepPipeline() {
const [stageIndex, setStageIndex] = useState(0);
const [filled, setFilled] = useState(0);
const [stageIndex, setStageIndex] = useState(0)
const [filled, setFilled] = useState(0)
const finished = stageIndex >= STAGES.length;
const finished = stageIndex >= STAGES.length
useEffect(() => {
if (finished) return;
if (finished) return
const timer = setTimeout(() => {
// Read from the closure and call setters directly — nesting one setter
// inside another's updater double-advances under React StrictMode.
if (filled + STEP_PER_TICK < STAGE_TOTAL) {
setFilled(filled + STEP_PER_TICK);
setFilled(filled + STEP_PER_TICK)
} else {
setStageIndex(stageIndex + 1);
setFilled(0);
setStageIndex(stageIndex + 1)
setFilled(0)
}
}, TICK_MS);
return () => clearTimeout(timer);
}, [filled, stageIndex, finished]);
}, TICK_MS)
return () => clearTimeout(timer)
}, [filled, stageIndex, finished])
const replay = () => {
setStageIndex(0);
setFilled(0);
};
setStageIndex(0)
setFilled(0)
}
const remaining = STAGE_TOTAL - filled;
const remaining = STAGE_TOTAL - filled
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
After indexing, Phokus works through a strict pipeline: thumbnails first, then video metadata,
then visual embeddings (for similarity and semantic search), then AI tags. One stage at a time,
so each runs at full speed.
After indexing, Phokus works through a strict pipeline: thumbnails first, then video
metadata, then visual embeddings (for similarity and semantic search), then AI tags. One
stage at a time, so each runs at full speed.
</p>
<p className="mt-5 text-xs text-gray-500">
@@ -49,41 +49,51 @@ export function StepPipeline() {
</p>
{/* 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">
<span className="relative flex h-1.5 w-1.5 shrink-0">
{!finished ? (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-60" />
) : 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 className="text-[13px] font-medium text-white/60 light-theme:text-gray-500">Holiday Photos</span>
<div className="flex items-center gap-1.5">
{STAGES.map((stage, i) => (
<FakeStageTag
key={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>
<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 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">
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever you like
the queue picks up where it left off next launch.
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever
you like the queue picks up where it left off next launch.
</p>
<p className="pt-2.5">
<span className="text-gray-300">Per-folder control.</span> Right-click a folder in the sidebar to
pause its background work, and click the bar itself to expand a detailed per-folder view.
<span className="text-gray-300">Per-folder control.</span> Right-click a folder in the
sidebar to pause its background work, and click the bar itself to expand a detailed
per-folder view.
</p>
</div>
{finished ? <ReplayButton onClick={replay} /> : null}
</div>
</div>
);
)
}
+59 -41
View File
@@ -1,83 +1,101 @@
import { useEffect, useState } from "react";
import { ReplayButton, SEARCH_RESULTS } from "./fakes";
import { useEffect, useState } from 'react'
import { ReplayButton, SEARCH_RESULTS } from './fakes'
const DEMOS = [
{
query: "beach-day_042.jpg",
mode: "Filename",
description: "Plain text matches file names — the default, instant search. One name, one file.",
query: 'beach-day_042.jpg',
mode: 'Filename',
description: 'Plain text matches file names — the default, instant search. One name, one file.',
results: SEARCH_RESULTS.filename,
},
{
query: "/s golden sunset over water",
mode: "Semantic",
description: "Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.",
query: '/s golden sunset over water',
mode: 'Semantic',
description:
"Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.",
results: SEARCH_RESULTS.semantic,
},
{
query: "/t landscape",
mode: "Tags",
description: "Search by AI or manual tags. Autocomplete suggests tags as you type.",
query: '/t landscape',
mode: 'Tags',
description: 'Search by AI or manual tags. Autocomplete suggests tags as you type.',
results: SEARCH_RESULTS.tags,
},
] as const;
] as const
const TYPE_MS = 55;
const HOLD_MS = 2600;
const TYPE_MS = 55
const HOLD_MS = 2600
/// Types each demo query, shows matching results, advances through all three
/// modes once, then stops on the last with a replay control.
export function StepSearchDemo() {
const [demoIndex, setDemoIndex] = useState(0);
const [typed, setTyped] = useState(0);
const [finished, setFinished] = useState(false);
const [demoIndex, setDemoIndex] = useState(0)
const [typed, setTyped] = useState(0)
const [finished, setFinished] = useState(false)
const demo = DEMOS[demoIndex];
const isLastDemo = demoIndex === DEMOS.length - 1;
const demo = DEMOS[demoIndex]
const isLastDemo = demoIndex === DEMOS.length - 1
useEffect(() => {
if (finished) return;
if (finished) return
if (typed < demo.query.length) {
const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS);
return () => clearTimeout(timer);
const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS)
return () => clearTimeout(timer)
}
// Fully typed: hold, then advance — or finish on the last demo.
const timer = setTimeout(() => {
if (isLastDemo) {
setFinished(true);
setFinished(true)
} else {
setDemoIndex((i) => i + 1);
setTyped(0);
setDemoIndex((i) => i + 1)
setTyped(0)
}
}, HOLD_MS);
return () => clearTimeout(timer);
}, [typed, demo.query.length, isLastDemo, finished]);
}, HOLD_MS)
return () => clearTimeout(timer)
}, [typed, demo.query.length, isLastDemo, finished])
const replay = () => {
setDemoIndex(0);
setTyped(0);
setFinished(false);
};
setDemoIndex(0)
setTyped(0)
setFinished(false)
}
const fullyTyped = typed >= demo.query.length;
const fullyTyped = typed >= demo.query.length
return (
<div>
<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
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.
One search bar, three modes picked by prefix. No prefix searches filenames,{' '}
<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>
{/* 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">
<svg 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" />
<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}
>
<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>
<span className="text-sm text-white">
{demo.query.slice(0, typed)}
{!finished ? <span className="animate-pulse text-gray-500">|</span> : null}
</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}
</span>
</div>
@@ -90,7 +108,7 @@ export function StepSearchDemo() {
demo's visible state. */}
<div
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) => (
<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}
</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,
// 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.
</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
</h4>
<div className="mt-1 py-4">
<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">
That aperture in the top-left corner is Phokus. When a new version is waiting, its focal point
glows amber click it to update and relaunch. Nothing installs on its own; you're always in
control.
That aperture in the top-left corner is Phokus. When a new version is waiting, its focal
point glows amber click it to update and relaunch. Nothing installs on its own; you're
always in control.
</p>
{/* 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="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2 light-theme:border-gray-300/70">
<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">
<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 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" />
<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="light-theme:border-gray-300/70 flex items-center gap-2 border-b border-white/[0.06] px-3 py-2">
<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 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 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" />
</div>
<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" />
</svg>
<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>
</div>
</div>
{/* Ghosted window body, just enough to read as the app. */}
<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="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>
<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?
</h4>
<div className="mt-1 py-4">
<p className="text-sm text-white">Check any time from Settings</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
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
schedule.
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 schedule.
</p>
</div>
<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
re-running this tour.
That's the tour. Add folders from the sidebar, and revisit any of this from Settings
including re-running this tour.
</p>
</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 (
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
@@ -9,7 +17,7 @@ function ViewRow({ title, description, preview }: { title: string; description:
</div>
<div className="shrink-0">{preview}</div>
</div>
);
)
}
function ExplorePreview() {
@@ -20,7 +28,7 @@ function ExplorePreview() {
{ size: 18, x: 86, y: 22, i: 4 },
{ size: 26, x: 30, y: 36, i: 1 },
{ size: 16, x: 70, y: 44, i: 6 },
];
]
return (
<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) => (
@@ -31,7 +39,7 @@ function ExplorePreview() {
/>
))}
</div>
);
)
}
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">
{[2024, 2023].map((year, row) => (
<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) => (
<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>
);
)
}
function DuplicatesPreview() {
@@ -57,10 +68,12 @@ function DuplicatesPreview() {
</div>
<div className="relative w-10">
<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>
);
)
}
export function StepViews() {
@@ -69,7 +82,7 @@ export function StepViews() {
<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:
</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
title="Explore"
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>
<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>
</div>
);
)
}
+113 -74
View File
@@ -1,149 +1,171 @@
import { AppTheme, useGalleryStore } from "../../store";
import { FakeProgressBar, formatBytes } from "./fakes";
import { AppTheme, useGalleryStore } from '../../store'
import { FakeProgressBar, formatBytes } from './fakes'
const THEME_OPTIONS: {
value: AppTheme;
name: string;
value: AppTheme
name: string
colors: {
background: string;
surface: string;
text: string;
muted: string;
accent: string;
};
background: string
surface: string
text: string
muted: string
accent: string
}
}[] = [
{
value: "phokus",
name: "Phokus",
value: 'phokus',
name: 'Phokus',
colors: {
background: "#030712",
surface: "#111827",
text: "#f9fafb",
muted: "#6b7280",
accent: "#10b981",
background: '#030712',
surface: '#111827',
text: '#f9fafb',
muted: '#6b7280',
accent: '#10b981',
},
},
{
value: "conventional-dark",
name: "Conventional Dark",
value: 'conventional-dark',
name: 'Conventional Dark',
colors: {
background: "#1f1f1f",
surface: "#303030",
text: "#a3a3a3",
muted: "#666666",
accent: "#10b981",
background: '#1f1f1f',
surface: '#303030',
text: '#a3a3a3',
muted: '#666666',
accent: '#10b981',
},
},
{
value: "subtle-light",
name: "Subtle Light",
value: 'subtle-light',
name: 'Subtle Light',
colors: {
background: "#e9e7e2",
surface: "#dedbd4",
text: "#18202c",
muted: "#817b72",
accent: "#059669",
background: '#e9e7e2',
surface: '#dedbd4',
text: '#18202c',
muted: '#817b72',
accent: '#059669',
},
},
];
]
export function FfmpegStatusRow() {
const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus);
const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress);
const ffmpegError = useGalleryStore((s) => s.ffmpegError);
const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload);
const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus)
const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress)
const ffmpegError = useGalleryStore((s) => s.ffmpegError)
const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload)
if (ffmpegStatus === "installed") {
if (ffmpegStatus === 'installed') {
return (
<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}>
<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
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>
<div>
<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>
);
)
}
if (ffmpegStatus === "error") {
if (ffmpegStatus === 'error') {
return (
<div className="py-3">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<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">
You can keep going photos work without it. Video thumbnails and metadata will start
automatically once the download succeeds.
</p>
</div>
<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()}
>
Retry download
</button>
</div>
</div>
);
)
}
const fraction =
ffmpegStatus === "downloading" && ffmpegProgress && ffmpegProgress.total_bytes > 0
ffmpegStatus === 'downloading' && ffmpegProgress && ffmpegProgress.total_bytes > 0
? ffmpegProgress.downloaded_bytes / ffmpegProgress.total_bytes
: null;
: null
return (
<div className="py-3">
<div className="flex items-baseline justify-between gap-6">
<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>
{ffmpegProgress ? (
<span className="text-[11px] tabular-nums text-gray-500">
{formatBytes(ffmpegProgress.downloaded_bytes)} / {formatBytes(ffmpegProgress.total_bytes)}
<span className="text-[11px] text-gray-500 tabular-nums">
{formatBytes(ffmpegProgress.downloaded_bytes)} /{' '}
{formatBytes(ffmpegProgress.total_bytes)}
</span>
) : null}
</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">
FFmpeg powers video thumbnails and metadata. It downloads once in the background you can keep
using the tour and the app while it finishes.
FFmpeg powers video thumbnails and metadata. It downloads once in the background you can
keep using the tour and the app while it finishes.
</p>
</div>
);
)
}
export function StepWelcome() {
const theme = useGalleryStore((s) => s.theme);
const setTheme = useGalleryStore((s) => s.setTheme);
const theme = useGalleryStore((s) => s.theme)
const setTheme = useGalleryStore((s) => s.setTheme)
return (
<div>
<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
gallery with thumbnails, AI tags, and visual search. Everything is processed on this machine
your photos and videos never leave it.
Phokus is a local-first media library: point it at your folders and it builds a fast,
searchable gallery with thumbnails, AI tags, and visual search. Everything is processed on
this machine your photos and videos never leave it.
</p>
<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
from Settings.
This short tour shows what to expect. Every step is skippable, and you can re-run it any
time from Settings.
</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">
{THEME_OPTIONS.map((option) => {
const selected = option.value === theme;
const selected = option.value === theme
return (
<button
key={option.value}
type="button"
className={`rounded-md border p-2 text-left transition-colors ${
selected
? "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"
? 'border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/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 border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white'
}`}
onClick={() => setTheme(option.value)}
>
@@ -151,23 +173,40 @@ export function StepWelcome() {
className="block overflow-hidden rounded border border-black/20 p-1.5"
style={{ backgroundColor: option.colors.background }}
>
<span className="mb-1 block h-2 w-8 rounded-sm" style={{ backgroundColor: option.colors.accent }} />
<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
className="mb-1 block h-2 w-8 rounded-sm"
style={{ backgroundColor: option.colors.accent }}
/>
<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 className="mt-2 block text-[11px] font-medium">{option.name}</span>
</button>
);
)
})}
</div>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">First-time setup</h4>
<div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<h4 className="mt-7 text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
First-time setup
</h4>
<div className="light-theme:divide-gray-300/70 mt-1 divide-y divide-white/[0.05]">
<FfmpegStatusRow />
</div>
</div>
);
)
}
+96 -59
View File
@@ -2,21 +2,21 @@
// deliberately fake — rights-clean demo stills (generated, owned), looping
// demo progress — so new users see the real UI's shapes before their own
// library exists.
import sunset from "../../assets/onboarding/sunset.webp";
import sunsetcoast from "../../assets/onboarding/sunsetcoast.webp";
import sunsetlake from "../../assets/onboarding/sunsetlake.webp";
import beach from "../../assets/onboarding/beach.webp";
import landscape1 from "../../assets/onboarding/landscape1.webp";
import landscape2 from "../../assets/onboarding/landscape2.webp";
import forest from "../../assets/onboarding/forest.webp";
import citynight from "../../assets/onboarding/citynight.webp";
import flower from "../../assets/onboarding/flower.webp";
import alpinelake from "../../assets/onboarding/alpinelake.webp";
import dunes from "../../assets/onboarding/dunes.webp";
import cozy from "../../assets/onboarding/cozy.webp";
import cat from "../../assets/onboarding/cat.webp";
import balloon from "../../assets/onboarding/balloon.webp";
import architecture from "../../assets/onboarding/architecture.webp";
import sunset from '../../assets/onboarding/sunset.webp'
import sunsetcoast from '../../assets/onboarding/sunsetcoast.webp'
import sunsetlake from '../../assets/onboarding/sunsetlake.webp'
import beach from '../../assets/onboarding/beach.webp'
import landscape1 from '../../assets/onboarding/landscape1.webp'
import landscape2 from '../../assets/onboarding/landscape2.webp'
import forest from '../../assets/onboarding/forest.webp'
import citynight from '../../assets/onboarding/citynight.webp'
import flower from '../../assets/onboarding/flower.webp'
import alpinelake from '../../assets/onboarding/alpinelake.webp'
import dunes from '../../assets/onboarding/dunes.webp'
import cozy from '../../assets/onboarding/cozy.webp'
import cat from '../../assets/onboarding/cat.webp'
import balloon from '../../assets/onboarding/balloon.webp'
import architecture from '../../assets/onboarding/architecture.webp'
// Ordered for grid variety: adjacent indices are visually distinct.
const FAKE_IMAGES = [
@@ -33,10 +33,10 @@ const FAKE_IMAGES = [
landscape2,
beach,
architecture,
];
]
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.
@@ -47,23 +47,23 @@ export const SEARCH_RESULTS = {
filename: [beach],
semantic: [sunsetcoast, sunset, sunsetlake],
tags: [landscape1, landscape2, alpinelake],
} as const;
} as const
// Abstract gradient blobs/ticks for the Explore & Timeline mini-previews,
// where small non-photographic shapes read more clearly than tiny stills.
const TILE_GRADIENTS = [
"from-sky-900/70 via-slate-800 to-slate-900",
"from-amber-900/60 via-stone-800 to-stone-900",
"from-emerald-900/60 via-slate-800 to-gray-900",
"from-rose-900/50 via-slate-800 to-slate-900",
"from-indigo-900/60 via-slate-800 to-gray-900",
"from-cyan-900/60 via-slate-800 to-slate-900",
"from-fuchsia-900/40 via-slate-800 to-gray-900",
"from-orange-900/50 via-stone-800 to-stone-900",
];
'from-sky-900/70 via-slate-800 to-slate-900',
'from-amber-900/60 via-stone-800 to-stone-900',
'from-emerald-900/60 via-slate-800 to-gray-900',
'from-rose-900/50 via-slate-800 to-slate-900',
'from-indigo-900/60 via-slate-800 to-gray-900',
'from-cyan-900/60 via-slate-800 to-slate-900',
'from-fuchsia-900/40 via-slate-800 to-gray-900',
'from-orange-900/50 via-stone-800 to-stone-900',
]
export function tileGradient(index: number): string {
return TILE_GRADIENTS[index % TILE_GRADIENTS.length];
return TILE_GRADIENTS[index % TILE_GRADIENTS.length]
}
export function FakeTile({
@@ -72,24 +72,31 @@ export function FakeTile({
favorite = false,
rating = 0,
duration,
className = "",
className = '',
}: {
index: number;
loaded?: boolean;
favorite?: boolean;
rating?: number;
duration?: string;
className?: string;
index: number
loaded?: boolean
favorite?: boolean
rating?: number
duration?: string
className?: string
}) {
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 ? (
<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]" />
)}
{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">
<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>
@@ -105,27 +112,41 @@ export function FakeTile({
</div>
) : null}
{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}
</div>
) : null}
</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 =
state === "active"
? "bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300"
: state === "done"
? "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";
return <span className={`rounded-md px-2 py-0.5 text-[11px] ${className}`}>{label}</span>;
state === 'active'
? 'bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300'
: state === 'done'
? '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'
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 (
<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 ? (
<div className="h-full w-full animate-pulse bg-blue-500/40" />
) : (
@@ -135,27 +156,43 @@ export function FakeProgressBar({ fraction, className = "" }: { fraction: number
/>
)}
</div>
);
)
}
export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => void; label?: string }) {
export function ReplayButton({
onClick,
label = 'Replay',
}: {
onClick: () => void
label?: string
}) {
return (
<button
type="button"
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}>
<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
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>
{label}
</button>
);
)
}
export function formatBytes(bytes: number): string {
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) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
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) return `${(bytes / 1024).toFixed(0)} KB`
return `${bytes} B`
}
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useGalleryStore } from "../../store";
import { TAGGER_MODELS } from "../../taggerModels";
import { useEffect, useMemo, useRef, useState } from 'react'
import { useGalleryStore } from '../../store'
import { TAGGER_MODELS } from '../../taggerModels'
import {
formatBytesShort,
ScopeButton,
@@ -10,207 +10,212 @@ import {
StatusPill,
TaggerAccelerationButton,
TaggerModelButton,
} from "./shared";
} from './shared'
export function AiWorkspaceSettingsSection() {
const folders = useGalleryStore((state) => state.folders);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope);
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds);
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
const taggerModelError = useGalleryStore((state) => state.taggerModelError);
const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration);
const taggerThreshold = useGalleryStore((state) => state.taggerThreshold);
const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize);
const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe);
const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking);
const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel);
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel);
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
const taggerModel = useGalleryStore((state) => state.taggerModel);
const setTaggerModel = useGalleryStore((state) => state.setTaggerModel);
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold);
const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize);
const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
const resetAiTags = useGalleryStore((state) => state.resetAiTags);
const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders);
const openTagManager = useGalleryStore((state) => state.openTagManager);
const folders = useGalleryStore((state) => state.folders)
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress)
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope)
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds)
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope)
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder)
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds)
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus)
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing)
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress)
const taggerModelError = useGalleryStore((state) => state.taggerModelError)
const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration)
const taggerThreshold = useGalleryStore((state) => state.taggerThreshold)
const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize)
const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe)
const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking)
const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel)
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel)
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration)
const taggerModel = useGalleryStore((state) => state.taggerModel)
const setTaggerModel = useGalleryStore((state) => state.setTaggerModel)
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold)
const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize)
const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime)
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs)
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders)
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs)
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders)
const resetAiTags = useGalleryStore((state) => state.resetAiTags)
const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders)
const openTagManager = useGalleryStore((state) => state.openTagManager)
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerResetConfirming, setTaggerResetConfirming] = useState(false);
const [taggerResetting, setTaggerResetting] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
const [taggerModelSwitching, setTaggerModelSwitching] = useState(false);
const [taggerModelSwitchError, setTaggerModelSwitchError] = useState<string | null>(null);
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null);
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null);
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
const [taggerBatchSizeError, setTaggerBatchSizeError] = useState<string | null>(null);
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null)
const [taggerQueueing, setTaggerQueueing] = useState(false)
const [taggerClearing, setTaggerClearing] = useState(false)
const [taggerResetConfirming, setTaggerResetConfirming] = useState(false)
const [taggerResetting, setTaggerResetting] = useState(false)
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false)
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null)
const [taggerModelSwitching, setTaggerModelSwitching] = useState(false)
const [taggerModelSwitchError, setTaggerModelSwitchError] = useState<string | null>(null)
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null)
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false)
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null)
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null)
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false)
const [taggerBatchSizeError, setTaggerBatchSizeError] = useState<string | null>(null)
const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
};
}, []);
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current)
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current)
}
}, [])
const selectedFolders = useMemo(
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
[folders, taggingQueueFolderIds],
);
[folders, taggingQueueFolderIds]
)
const taggerReady = taggerModelStatus?.ready ?? false;
const taggerReady = taggerModelStatus?.ready ?? false
const queueScopeLabel =
taggingQueueScope === "all"
? "all media"
taggingQueueScope === 'all'
? 'all media'
: selectedFolders.length > 0
? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? "" : "s"}`
: "no folders selected";
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold);
const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize);
? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? '' : 's'}`
: 'no folders selected'
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold)
const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize)
const taggerBytesKnown =
taggerModelProgress?.downloaded_bytes != null &&
taggerModelProgress.total_bytes != null &&
taggerModelProgress.total_bytes > 0;
taggerModelProgress.total_bytes > 0
const taggerDownloadLabel = taggerBytesKnown
? `Downloading ${formatBytesShort(taggerModelProgress!.downloaded_bytes!)} / ${formatBytesShort(taggerModelProgress!.total_bytes!)}`
: taggerModelProgress
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
: taggerModelPreparing
? "Preparing AI tagger..."
? 'Preparing AI tagger...'
: taggerReady
? "Installed"
: "Install model";
? 'Installed'
: 'Install model'
const taggerDownloadPercent = taggerBytesKnown
? Math.round((taggerModelProgress!.downloaded_bytes! / taggerModelProgress!.total_bytes!) * 100)
: taggerModelProgress
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100)
: 0;
? Math.round(
(taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100
)
: 0
const runQueueAction = (action: "queue" | "clear") => {
const selectedIds = taggingQueueFolderIds;
const runQueueAction = (action: 'queue' | 'clear') => {
const selectedIds = taggingQueueFolderIds
const perform =
taggingQueueScope === "all"
? action === "queue"
taggingQueueScope === 'all'
? action === 'queue'
? queueTaggingJobs(null)
: clearTaggingJobs(null)
: selectedIds.length > 0
? action === "queue"
? action === 'queue'
? queueTaggingJobsForFolders(selectedIds)
: clearTaggingJobsForFolders(selectedIds)
: Promise.resolve(0);
: Promise.resolve(0)
if (action === "queue") {
setTaggerQueueing(true);
if (action === 'queue') {
setTaggerQueueing(true)
} else {
setTaggerClearing(true);
setTaggerClearing(true)
}
setTaggerQueueStatus(null);
setTaggerResetConfirming(false);
setTaggerQueueStatus(null)
setTaggerResetConfirming(false)
void perform
.then((count) => {
if (taggingQueueScope === "selected" && selectedIds.length === 0) {
setTaggerQueueStatus("Choose at least one folder before running tagging jobs.");
return;
if (taggingQueueScope === 'selected' && selectedIds.length === 0) {
setTaggerQueueStatus('Choose at least one folder before running tagging jobs.')
return
}
setTaggerQueueStatus(
count === 0
? action === "queue"
? "No missing tags found for the current target."
: "No queued tagging jobs to clear for the current target."
: action === "queue"
? `Queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for tagging.`
: `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? "" : "s"}.`,
);
? action === 'queue'
? 'No missing tags found for the current target.'
: 'No queued tagging jobs to clear for the current target.'
: action === 'queue'
? `Queued ${count.toLocaleString()} image${count === 1 ? '' : 's'} for tagging.`
: `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? '' : 's'}.`
)
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
if (action === "queue") {
setTaggerQueueing(false);
if (action === 'queue') {
setTaggerQueueing(false)
} else {
setTaggerClearing(false);
setTaggerClearing(false)
}
})
}
});
};
const runResetAiTags = () => {
if (!taggerResetConfirming) {
setTaggerResetConfirming(true);
setTaggerResetConfirming(true)
setTaggerQueueStatus(
`Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`,
);
return;
`Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`
)
return
}
const selectedIds = taggingQueueFolderIds;
const selectedIds = taggingQueueFolderIds
const perform =
taggingQueueScope === "all"
taggingQueueScope === 'all'
? resetAiTags(null)
: selectedIds.length > 0
? resetAiTagsForFolders(selectedIds)
: Promise.resolve(0);
: Promise.resolve(0)
setTaggerResetting(true);
setTaggerQueueStatus(null);
setTaggerResetting(true)
setTaggerQueueStatus(null)
void perform
.then((count) => {
if (taggingQueueScope === "selected" && selectedIds.length === 0) {
setTaggerQueueStatus("Choose at least one folder before resetting AI tags.");
return;
if (taggingQueueScope === 'selected' && selectedIds.length === 0) {
setTaggerQueueStatus('Choose at least one folder before resetting AI tags.')
return
}
setTaggerQueueStatus(
count === 0
? "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.`,
);
? '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.`
)
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerResetting(false);
setTaggerResetConfirming(false);
});
};
setTaggerResetting(false)
setTaggerResetConfirming(false)
})
}
return (
<div className="mt-8 space-y-9">
<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 rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
{(["wd", "joytag"] as const).map((model) => (
<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) => (
<TaggerModelButton
key={model}
model={model}
current={taggerModel}
onSelect={(nextModel) => {
if (nextModel === taggerModel) return;
setTaggerThresholdDraft(null);
setTaggerThresholdError(null);
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
setTaggerModelSwitching(true);
setTaggerModelSwitchError(null);
if (nextModel === taggerModel) return
setTaggerThresholdDraft(null)
setTaggerThresholdError(null)
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current)
setTaggerModelSwitching(true)
setTaggerModelSwitchError(null)
void setTaggerModel(nextModel)
.catch((error: unknown) => setTaggerModelSwitchError(String(error)))
.finally(() => setTaggerModelSwitching(false));
.finally(() => setTaggerModelSwitching(false))
}}
>
{TAGGER_MODELS[model].tab}
@@ -220,7 +225,11 @@ export function AiWorkspaceSettingsSection() {
{taggerModelSwitchError ? (
<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>
</SettingsItem>
@@ -228,10 +237,10 @@ export function AiWorkspaceSettingsSection() {
<SettingsItem
label={
<>
{TAGGER_MODELS[taggerModel].name}{" "}
{TAGGER_MODELS[taggerModel].name}{' '}
<span className="ml-1.5 align-middle">
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
<StatusPill tone={taggerReady ? 'ready' : taggerModelPreparing ? 'busy' : 'muted'}>
{taggerReady ? 'Installed' : taggerModelPreparing ? 'Preparing' : 'Not installed'}
</StatusPill>
</span>
</>
@@ -242,11 +251,15 @@ export function AiWorkspaceSettingsSection() {
<div className="flex items-center gap-2">
{taggerReady ? (
<>
<button className={settingsButtonClass} onClick={() => void probeTaggerRuntime()} disabled={taggerRuntimeChecking}>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
<button
className={settingsButtonClass}
onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? 'Checking runtime...' : 'Check runtime'}
</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()}
disabled={taggerModelPreparing}
>
@@ -254,8 +267,17 @@ export function AiWorkspaceSettingsSection() {
</button>
</>
) : (
<button 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}
<button
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>
</button>
)}
@@ -263,44 +285,63 @@ export function AiWorkspaceSettingsSection() {
{taggerRuntimeProbe ? (
<div className="text-right">
<p className="text-xs text-gray-400">
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
{" "}<span className="ml-2 text-gray-600">· acceleration: {taggerRuntimeProbe.acceleration}</span>
Runtime check{' '}
<span className="ml-1.5 align-middle">
<StatusPill tone="ready">Ready</StatusPill>
</span>{' '}
<span className="ml-2 text-gray-600">
· acceleration: {taggerRuntimeProbe.acceleration}
</span>
</p>
<p className="mt-1 font-mono text-xs break-all text-gray-600">
{taggerRuntimeProbe.session.file}
</p>
<p className="mt-1 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
</div>
) : null}
</div>
</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 rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
<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) => (
<TaggerAccelerationButton
key={acceleration}
acceleration={acceleration}
current={taggerAcceleration}
onSelect={(nextAcceleration) => {
setTaggerAccelerationSaving(true);
setTaggerAccelerationError(null);
setTaggerAccelerationSaving(true)
setTaggerAccelerationError(null)
void setTaggerAcceleration(nextAcceleration)
.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>
))}
</div>
{taggerAccelerationError ? (
<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>
</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">
<input
type="number"
@@ -311,33 +352,43 @@ export function AiWorkspaceSettingsSection() {
value={thresholdDisplay}
onChange={(event) => setTaggerThresholdDraft(event.target.value)}
onBlur={(event) => {
const value = parseFloat(event.currentTarget.value);
const value = parseFloat(event.currentTarget.value)
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdError(null);
setTaggerThresholdSaving(true);
setTaggerThresholdError(null)
setTaggerThresholdSaving(true)
void setTaggerThreshold(value, taggerModel)
.catch((error: unknown) => setTaggerThresholdError(String(error)))
.finally(() => {
setTaggerThresholdDraft(null);
setTaggerThresholdSaving(false);
});
setTaggerThresholdDraft(null)
setTaggerThresholdSaving(false)
})
} else {
setTaggerThresholdDraft(null);
setTaggerThresholdError("Must be 0.05 0.99");
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
setTaggerThresholdDraft(null)
setTaggerThresholdError('Must be 0.05 0.99')
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current)
thresholdErrorTimerRef.current = setTimeout(
() => setTaggerThresholdError(null),
2000
)
}
}}
/>
{taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : `Default: ${TAGGER_MODELS[taggerModel].defaultThreshold}`}</p>
<p className="text-[11px] text-gray-600">
{taggerThresholdSaving
? 'Saving...'
: `Default: ${TAGGER_MODELS[taggerModel].defaultThreshold}`}
</p>
)}
</div>
</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">
<input
type="number"
@@ -348,48 +399,73 @@ export function AiWorkspaceSettingsSection() {
value={batchSizeDisplay}
onChange={(event) => setTaggerBatchSizeDraft(event.target.value)}
onBlur={() => {
const value = parseInt(batchSizeDisplay, 10);
const value = parseInt(batchSizeDisplay, 10)
if (!isNaN(value) && value >= 1 && value <= 100) {
setTaggerBatchSizeError(null);
setTaggerBatchSizeSaving(true);
setTaggerBatchSizeError(null)
setTaggerBatchSizeSaving(true)
void setTaggerBatchSize(value)
.catch((error: unknown) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerBatchSizeDraft(null);
setTaggerBatchSizeSaving(false);
});
setTaggerBatchSizeDraft(null)
setTaggerBatchSizeSaving(false)
})
} else {
setTaggerBatchSizeDraft(null);
setTaggerBatchSizeError("Must be 1 100");
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
setTaggerBatchSizeDraft(null)
setTaggerBatchSizeError('Must be 1 100')
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current)
batchSizeErrorTimerRef.current = setTimeout(
() => setTaggerBatchSizeError(null),
2000
)
}
}}
/>
{taggerBatchSizeError ? (
<p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
<p className="text-[11px] text-gray-600">
{taggerBatchSizeSaving ? 'Saving...' : 'Default: 8'}
</p>
)}
</div>
</SettingsItem>
<SettingsItem label="Model location" vertical>
<div>
<p className="break-all font-mono text-xs text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
<p className="font-mono text-xs break-all text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : 'Not downloaded'}
</p>
{taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
{taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null}
{taggerModelProgress?.current_file ? (
<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>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Queue targets" description="Choose which folders to include when queuing tagging jobs.">
<SettingsItem label="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
<SettingsGroup
title="Queue targets"
description="Choose which folders to include when queuing tagging jobs."
>
<SettingsItem
label="Target scope"
description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it."
>
<div className="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>
</SettingsItem>
@@ -397,86 +473,123 @@ export function AiWorkspaceSettingsSection() {
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-white">Folder selection</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
Current target: {queueScopeLabel}.
</p>
</div>
<div className="flex gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed 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))}
disabled={taggingQueueScope === "all" || folders.length === 0}
disabled={taggingQueueScope === 'all' || folders.length === 0}
>
Select all
</button>
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed 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([])}
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
disabled={taggingQueueScope === 'all' || taggingQueueFolderIds.length === 0}
>
Clear
</button>
</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) => {
const active = taggingQueueFolderIds.includes(folder.id);
const progress = mediaJobProgress[folder.id];
const active = taggingQueueFolderIds.includes(folder.id)
const progress = mediaJobProgress[folder.id]
return (
<button
key={folder.id}
type="button"
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)}
disabled={taggingQueueScope === "all"}
disabled={taggingQueueScope === 'all'}
>
<p className="min-w-0 truncate text-sm">{folder.name}</p>
<div className="flex shrink-0 items-center gap-3">
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()} items</span>
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
{(progress?.tagging_pending ?? 0) > 0 ? (
<StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill>
) : 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>
</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>
<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">
<button
className={settingsButtonClass}
onClick={() => runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
onClick={() => runQueueAction('queue')}
disabled={
!taggerReady ||
taggerQueueing ||
taggerClearing ||
taggerResetting ||
(taggingQueueScope === 'selected' && taggingQueueFolderIds.length === 0)
}
>
{taggerQueueing ? "Queueing..." : "Queue tagging"}
{taggerQueueing ? 'Queueing...' : 'Queue tagging'}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100"
onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
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')}
disabled={
taggerQueueing ||
taggerClearing ||
taggerResetting ||
(taggingQueueScope === 'selected' && taggingQueueFolderIds.length === 0)
}
>
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
{taggerClearing ? 'Clearing...' : 'Clear queued jobs'}
</button>
<button
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
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"
: "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-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'
: '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}
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>
{taggerResetConfirming ? (
<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={() => {
setTaggerResetConfirming(false);
setTaggerQueueStatus(null);
setTaggerResetConfirming(false)
setTaggerQueueStatus(null)
}}
disabled={taggerResetting}
>
@@ -486,16 +599,24 @@ export function AiWorkspaceSettingsSection() {
</div>
</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 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.">
<SettingsGroup
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}>
Open tag manager
</button>
</SettingsItem>
</SettingsGroup>
</div>
);
)
}
@@ -1,27 +1,30 @@
import { Dropdown } from "../menu";
import { useGalleryStore } from "../../store";
import { SettingsGroup, SettingsItem } from "./shared";
import { Dropdown } from '../menu'
import { useGalleryStore } from '../../store'
import { SettingsGroup, SettingsItem } from './shared'
export function GeneralSettingsSection() {
const theme = useGalleryStore((state) => state.theme);
const setTheme = useGalleryStore((state) => state.setTheme);
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
const workerPausesPersist = useGalleryStore((state) => state.workerPausesPersist);
const setWorkerPausesPersist = useGalleryStore((state) => state.setWorkerPausesPersist);
const theme = useGalleryStore((state) => state.theme)
const setTheme = useGalleryStore((state) => state.setTheme)
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused)
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused)
const workerPausesPersist = useGalleryStore((state) => state.workerPausesPersist)
const setWorkerPausesPersist = useGalleryStore((state) => state.setWorkerPausesPersist)
return (
<div className="mt-8 space-y-9">
<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
value={theme}
onChange={setTheme}
ariaLabel="App theme"
options={[
{ value: "phokus", label: "Phokus" },
{ value: "subtle-light", label: "Subtle Light" },
{ value: "conventional-dark", label: "Conventional Dark" },
{ value: 'phokus', label: 'Phokus' },
{ value: 'subtle-light', label: 'Subtle Light' },
{ value: 'conventional-dark', label: 'Conventional Dark' },
]}
/>
</SettingsItem>
@@ -35,10 +38,12 @@ export function GeneralSettingsSection() {
<button
role="switch"
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)}
>
<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>
</SettingsItem>
<SettingsItem
@@ -48,13 +53,15 @@ export function GeneralSettingsSection() {
<button
role="switch"
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)}
>
<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>
</SettingsItem>
</SettingsGroup>
</div>
);
)
}
@@ -1,18 +1,18 @@
import { Dropdown } from "../menu";
import { useGalleryStore } from "../../store";
import { SettingsGroup, SettingsItem } from "./shared";
import { Dropdown } from '../menu'
import { useGalleryStore } from '../../store'
import { SettingsGroup, SettingsItem } from './shared'
export function MediaSettingsSection() {
const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay);
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute);
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds);
const setSlideshowIntervalSeconds = useGalleryStore((state) => state.setSlideshowIntervalSeconds);
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder);
const setSlideshowOrder = useGalleryStore((state) => state.setSlideshowOrder);
const slideshowTransition = useGalleryStore((state) => state.slideshowTransition);
const setSlideshowTransition = useGalleryStore((state) => state.setSlideshowTransition);
const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay)
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay)
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute)
const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute)
const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds)
const setSlideshowIntervalSeconds = useGalleryStore((state) => state.setSlideshowIntervalSeconds)
const slideshowOrder = useGalleryStore((state) => state.slideshowOrder)
const setSlideshowOrder = useGalleryStore((state) => state.setSlideshowOrder)
const slideshowTransition = useGalleryStore((state) => state.slideshowTransition)
const setSlideshowTransition = useGalleryStore((state) => state.setSlideshowTransition)
return (
<div className="mt-8 space-y-9">
@@ -24,10 +24,12 @@ export function MediaSettingsSection() {
<button
role="switch"
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)}
>
<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>
</SettingsItem>
<SettingsItem
@@ -37,10 +39,12 @@ export function MediaSettingsSection() {
<button
role="switch"
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)}
>
<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>
</SettingsItem>
</SettingsGroup>
@@ -61,7 +65,9 @@ export function MediaSettingsSection() {
className="w-32 accent-sky-500"
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>
</SettingsItem>
<SettingsItem
@@ -73,8 +79,8 @@ export function MediaSettingsSection() {
onChange={setSlideshowOrder}
ariaLabel="Slideshow order"
options={[
{ value: "sequential", label: "Sequential" },
{ value: "random", label: "Random" },
{ value: 'sequential', label: 'Sequential' },
{ value: 'random', label: 'Random' },
]}
/>
</SettingsItem>
@@ -87,12 +93,12 @@ export function MediaSettingsSection() {
onChange={setSlideshowTransition}
ariaLabel="Slideshow transition"
options={[
{ value: "soft-fade", label: "Soft fade" },
{ value: "gentle-motion", label: "Gentle motion" },
{ value: 'soft-fade', label: 'Soft fade' },
{ value: 'gentle-motion', label: 'Gentle motion' },
]}
/>
</SettingsItem>
</SettingsGroup>
</div>
);
)
}
@@ -1,31 +1,42 @@
import { useEffect, useState } from "react";
import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, VacuumResult, useGalleryStore } from "../../store";
import { SettingsGroup, SettingsItem, settingsButtonClass, StatPair } from "./shared";
import { useEffect, useState } from 'react'
import {
CleanupOrphanedThumbnailsResult,
DatabaseInfo,
OrphanedThumbnailsInfo,
VacuumResult,
useGalleryStore,
} from '../../store'
import { SettingsGroup, SettingsItem, settingsButtonClass, StatPair } from './shared'
export function StorageSettingsSection() {
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex);
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder)
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo)
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase)
const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex)
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo)
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails)
const [openingDataFolder, setOpeningDataFolder] = useState(false);
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null);
const [vacuuming, setVacuuming] = useState(false);
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
const [rebuildingIndex, setRebuildingIndex] = useState(false);
const [rebuildIndexResult, setRebuildIndexResult] = useState<string | null>(null);
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
const [openingDataFolder, setOpeningDataFolder] = useState(false)
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null)
const [vacuuming, setVacuuming] = useState(false)
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null)
const [rebuildingIndex, setRebuildingIndex] = useState(false)
const [rebuildIndexResult, setRebuildIndexResult] = useState<string | null>(null)
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null)
const [cleaningThumbnails, setCleaningThumbnails] = useState(false)
const [thumbnailCleanupResult, setThumbnailCleanupResult] =
useState<CleanupOrphanedThumbnailsResult | null>(null)
useEffect(() => {
setVacuumResult(null);
setThumbnailCleanupResult(null);
void getDatabaseInfo().then(setDbInfo).catch(() => {});
void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {});
}, [getDatabaseInfo, getOrphanedThumbnailsInfo]);
setVacuumResult(null)
setThumbnailCleanupResult(null)
void getDatabaseInfo()
.then(setDbInfo)
.catch(() => {})
void getOrphanedThumbnailsInfo()
.then(setThumbnailInfo)
.catch(() => {})
}, [getDatabaseInfo, getOrphanedThumbnailsInfo])
return (
<div className="mt-8 space-y-9">
@@ -37,12 +48,12 @@ export function StorageSettingsSection() {
<button
className={settingsButtonClass}
onClick={() => {
setOpeningDataFolder(true);
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
setOpeningDataFolder(true)
void openAppDataFolder().finally(() => setOpeningDataFolder(false))
}}
disabled={openingDataFolder}
>
{openingDataFolder ? "Opening..." : "Open data folder"}
{openingDataFolder ? 'Opening...' : 'Open data folder'}
</button>
</SettingsItem>
</SettingsGroup>
@@ -52,7 +63,10 @@ export function StorageSettingsSection() {
label="Compact database"
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">
<StatPair
label="Size"
@@ -61,7 +75,7 @@ export function StorageSettingsSection() {
? `${vacuumResult.after_mb.toFixed(1)} MB`
: dbInfo
? `${dbInfo.size_mb.toFixed(1)} MB`
: "—"
: '—'
}
/>
<StatPair
@@ -72,7 +86,7 @@ export function StorageSettingsSection() {
? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
: dbInfo
? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
: "—"
: '—'
}
/>
</span>
@@ -80,8 +94,8 @@ export function StorageSettingsSection() {
{vacuumResult
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
: dbInfo && dbInfo.reclaimable_mb < 0.5
? "Database is already compact."
: "Run this after removing folders or bulk-deleting images."}
? 'Database is already compact.'
: 'Run this after removing folders or bulk-deleting images.'}
</span>
</>
}
@@ -89,19 +103,19 @@ export function StorageSettingsSection() {
<button
className={settingsButtonClass}
onClick={() => {
setVacuuming(true);
setVacuumResult(null);
setVacuuming(true)
setVacuumResult(null)
void vacuumDatabase()
.then((result) => {
setVacuumResult(result);
setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
setVacuumResult(result)
setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 })
})
.catch(() => {})
.finally(() => setVacuuming(false));
.finally(() => setVacuuming(false))
}}
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
>
{vacuuming ? "Compacting..." : "Compact now"}
{vacuuming ? 'Compacting...' : 'Compact now'}
</button>
</SettingsItem>
@@ -110,10 +124,9 @@ export function StorageSettingsSection() {
description={
<>
<span>
Recreates the visual-embedding index and re-embeds every image in the
background. Use this if semantic or similar-image search reports a
dimension-mismatch error (for example after experimenting with a
different embedding model).
Recreates the visual-embedding index and re-embeds every image in the background.
Use this if semantic or similar-image search reports a dimension-mismatch error (for
example after experimenting with a different embedding model).
</span>
{rebuildIndexResult !== null ? (
<span className="mt-2 block text-gray-600">{rebuildIndexResult}</span>
@@ -124,20 +137,20 @@ export function StorageSettingsSection() {
<button
className={settingsButtonClass}
onClick={() => {
setRebuildingIndex(true);
setRebuildIndexResult(null);
setRebuildingIndex(true)
setRebuildIndexResult(null)
void rebuildSemanticIndex()
.then((count) =>
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)))
.finally(() => setRebuildingIndex(false));
.finally(() => setRebuildingIndex(false))
}}
disabled={rebuildingIndex}
>
{rebuildingIndex ? "Rebuilding…" : "Rebuild index"}
{rebuildingIndex ? 'Rebuilding…' : 'Rebuild index'}
</button>
</SettingsItem>
@@ -145,16 +158,19 @@ export function StorageSettingsSection() {
label="Thumbnail cache"
description={
<>
<span>Thumbnails left behind when folders or images are removed. Safe to delete they are regenerated if the originals are re-indexed.</span>
<span>
Thumbnails left behind when folders or images are removed. Safe to delete they are
regenerated if the originals are re-indexed.
</span>
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<StatPair
label="Orphaned files"
value={
thumbnailCleanupResult
? "0"
? '0'
: thumbnailInfo
? thumbnailInfo.count.toLocaleString()
: "—"
: '—'
}
/>
<StatPair
@@ -165,20 +181,20 @@ export function StorageSettingsSection() {
? `${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed`
: thumbnailInfo
? `${thumbnailInfo.size_mb.toFixed(1)} MB`
: "—"
: '—'
}
/>
</span>
<span className="mt-2 block text-gray-600">
{cleaningThumbnails
? "Scanning and removing orphaned thumbnails…"
? 'Scanning and removing orphaned thumbnails…'
: 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
? "No orphaned thumbnails found."
? 'No orphaned thumbnails found.'
: thumbnailInfo && thumbnailInfo.count > 1000
? "May take a few minutes for large collections."
: "Remove thumbnails no longer associated with any indexed image."}
? 'May take a few minutes for large collections.'
: 'Remove thumbnails no longer associated with any indexed image.'}
</span>
</>
}
@@ -186,21 +202,25 @@ export function StorageSettingsSection() {
<button
className={settingsButtonClass}
onClick={() => {
setCleaningThumbnails(true);
setCleaningThumbnails(true)
cleanupOrphanedThumbnails()
.then((result) => {
setThumbnailCleanupResult(result);
setThumbnailInfo(null);
setThumbnailCleanupResult(result)
setThumbnailInfo(null)
})
.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>
</SettingsItem>
</SettingsGroup>
</div>
);
)
}
@@ -1,20 +1,20 @@
import { getChangelogForVersion } from "../../changelog";
import { useGalleryStore } from "../../store";
import { FfmpegStatusRow } from "../onboarding/StepWelcome";
import { SettingsGroup, SettingsItem, settingsButtonClass, StatusPill } from "./shared";
import { getChangelogForVersion } from '../../changelog'
import { useGalleryStore } from '../../store'
import { FfmpegStatusRow } from '../onboarding/StepWelcome'
import { SettingsGroup, SettingsItem, settingsButtonClass, StatusPill } from './shared'
export function UpdatesSettingsSection() {
const appVersion = useGalleryStore((state) => state.appVersion);
const buildVariant = useGalleryStore((state) => state.buildVariant);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const updateProgress = useGalleryStore((state) => state.updateProgress);
const updateError = useGalleryStore((state) => state.updateError);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const installUpdate = useGalleryStore((state) => state.installUpdate);
const openWhatsNew = useGalleryStore((state) => state.openWhatsNew);
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const appVersion = useGalleryStore((state) => state.appVersion)
const buildVariant = useGalleryStore((state) => state.buildVariant)
const updateStatus = useGalleryStore((state) => state.updateStatus)
const updateVersion = useGalleryStore((state) => state.updateVersion)
const updateProgress = useGalleryStore((state) => state.updateProgress)
const updateError = useGalleryStore((state) => state.updateError)
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates)
const installUpdate = useGalleryStore((state) => state.installUpdate)
const openWhatsNew = useGalleryStore((state) => state.openWhatsNew)
const openOnboarding = useGalleryStore((state) => state.openOnboarding)
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen)
return (
<div className="mt-8 space-y-9">
@@ -22,47 +22,55 @@ export function UpdatesSettingsSection() {
<SettingsItem
label={
<span className="inline-flex items-center gap-2.5">
<span>Phokus {appVersion ? `v${appVersion}` : "—"}</span>
<span>Phokus {appVersion ? `v${appVersion}` : '—'}</span>
{buildVariant ? (
<StatusPill tone={buildVariant === "cuda" ? "ready" : "muted"}>
{buildVariant === "cuda" ? "CUDA" : "CPU"}
<StatusPill tone={buildVariant === 'cuda' ? 'ready' : 'muted'}>
{buildVariant === 'cuda' ? 'CUDA' : 'CPU'}
</StatusPill>
) : null}
{updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? (
{updateStatus === 'available' ||
updateStatus === 'downloading' ||
updateStatus === 'installing' ? (
<StatusPill tone="busy">v{updateVersion} available</StatusPill>
) : updateStatus === "upToDate" ? (
) : updateStatus === 'upToDate' ? (
<StatusPill tone="ready">Up to date</StatusPill>
) : null}
</span>
}
description={
updateStatus === "error" ? (
updateStatus === 'error' ? (
<span className="text-amber-300/90">Update check failed: {updateError}</span>
) : updateStatus === "downloading" || updateStatus === "installing" ? (
) : updateStatus === 'downloading' || updateStatus === 'installing' ? (
<span className="block">
<span className="text-gray-400">
{updateStatus === "installing"
? "Installing update…"
{updateStatus === 'installing'
? 'Installing update…'
: updateProgress !== null
? `Downloading update — ${Math.round(updateProgress * 100)}%`
: "Downloading update…"}
: 'Downloading update…'}
</span>
<span className="mt-2 block h-1 overflow-hidden rounded-full bg-white/10">
<span
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 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>
) : (
"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
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()}
@@ -73,9 +81,13 @@ export function UpdatesSettingsSection() {
<button
className={settingsButtonClass}
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>
)}
</SettingsItem>
@@ -100,8 +112,8 @@ export function UpdatesSettingsSection() {
<button
className={settingsButtonClass}
onClick={() => {
setSettingsOpen(false);
openOnboarding();
setSettingsOpen(false)
openOnboarding()
}}
>
Show welcome tour
@@ -109,5 +121,5 @@ export function UpdatesSettingsSection() {
</SettingsItem>
</SettingsGroup>
</div>
);
)
}
+123 -67
View File
@@ -1,148 +1,204 @@
import { ReactNode } from "react";
import { TaggerAcceleration, TaggerModel, TaggingQueueScope } from "../../store";
import { ReactNode } from 'react'
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 }[] = [
{ id: "general", label: "General", detail: "Theme and notifications" },
{ id: "media", label: "Media", detail: "Playback and slideshow" },
{ id: "updates", label: "Updates & Setup", detail: "Versions, setup, and tour" },
{ id: "storage", label: "Storage", detail: "App data and maintenance" },
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
];
{ id: 'general', label: 'General', detail: 'Theme and notifications' },
{ id: 'media', label: 'Media', detail: 'Playback and slideshow' },
{ id: 'updates', label: 'Updates & Setup', detail: 'Versions, setup, and tour' },
{ id: 'storage', label: 'Storage', detail: 'App data and maintenance' },
{ id: 'workspace', label: 'AI Workspace', detail: 'Tagging models and queue targets' },
]
export function formatBytesShort(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
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 =
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"
: 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-white/10 bg-white/[0.04] text-gray-500";
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'
: 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-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 }: {
title: string;
description?: string;
children: ReactNode;
export function SettingsGroup({
title,
description,
children,
}: {
title: string
description?: string
children: ReactNode
}) {
return (
<section>
<h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">{title}</h4>
{description ? <p className="mt-1 text-xs leading-relaxed text-gray-600">{description}</p> : null}
<h4 className="text-[12px] font-semibold tracking-[0.08em] text-gray-400 uppercase">
{title}
</h4>
{description ? (
<p className="mt-1 text-xs leading-relaxed text-gray-600">{description}</p>
) : null}
<div className="mt-1 divide-y divide-white/[0.05]">{children}</div>
</section>
);
)
}
export function SettingsItem({ label, description, children, vertical = false }: {
label: ReactNode;
description?: ReactNode;
children?: ReactNode;
vertical?: boolean;
export function SettingsItem({
label,
description,
children,
vertical = false,
}: {
label: ReactNode
description?: ReactNode
children?: ReactNode
vertical?: boolean
}) {
if (vertical) {
return (
<div className="py-4">
<p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div> : null}
{description ? (
<div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div>
) : null}
{children ? <div className="mt-3">{children}</div> : null}
</div>
);
)
}
return (
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">{description}</div> : null}
{description ? (
<div className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">{description}</div>
) : null}
</div>
<div className="shrink-0">{children}</div>
</div>
);
)
}
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 (
<span className="inline-flex items-baseline gap-2">
<span className="text-[10px] uppercase tracking-[0.14em] text-gray-600">{label}</span>
<span className={`text-sm font-semibold tabular-nums ${accent ? "text-emerald-300" : "text-white"}`}>{value}</span>
<span 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>
)
}
export function ScopeButton({ scope, current, onSelect, children }: {
scope: TaggingQueueScope;
current: TaggingQueueScope;
onSelect: (scope: TaggingQueueScope) => void;
children: ReactNode;
export function ScopeButton({
scope,
current,
onSelect,
children,
}: {
scope: TaggingQueueScope
current: TaggingQueueScope
onSelect: (scope: TaggingQueueScope) => void
children: ReactNode
}) {
const active = scope === current;
const active = scope === current
return (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "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: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'
: '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)}
>
{children}
</button>
);
)
}
export function TaggerModelButton({ model, current, onSelect, children }: {
model: TaggerModel;
current: TaggerModel;
onSelect: (model: TaggerModel) => void;
children: ReactNode;
export function TaggerModelButton({
model,
current,
onSelect,
children,
}: {
model: TaggerModel
current: TaggerModel
onSelect: (model: TaggerModel) => void
children: ReactNode
}) {
const active = model === current;
const active = model === current
return (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "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: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'
: '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)}
>
{children}
</button>
);
)
}
export function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
acceleration: TaggerAcceleration;
current: TaggerAcceleration;
onSelect: (acceleration: TaggerAcceleration) => void;
children: ReactNode;
export function TaggerAccelerationButton({
acceleration,
current,
onSelect,
children,
}: {
acceleration: TaggerAcceleration
current: TaggerAcceleration
onSelect: (acceleration: TaggerAcceleration) => void
children: ReactNode
}) {
const active = acceleration === current;
const active = acceleration === current
return (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "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: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'
: '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)}
>
{children}
</button>
);
)
}
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'
+69 -61
View File
@@ -1,12 +1,12 @@
import { useState } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { useGalleryStore, type Album } from "../../store";
import { mediaSrc } from "../../lib/mediaSrc";
import { ContextMenu, MenuItem, MenuSeparator } from "../menu";
import { InlineConfirm } from "../InlineConfirm";
import { InlineRename } from "../InlineRename";
import { Tooltip } from "../Tooltip";
import { CheckIcon, PhotoIcon } from "../icons";
import { useState } from 'react'
import { Reorder, useDragControls } from 'framer-motion'
import { useGalleryStore, type Album } from '../../store'
import { mediaSrc } from '../../lib/mediaSrc'
import { ContextMenu, MenuItem, MenuSeparator } from '../menu'
import { InlineConfirm } from '../InlineConfirm'
import { InlineRename } from '../InlineRename'
import { Tooltip } from '../Tooltip'
import { CheckIcon, PhotoIcon } from '../icons'
export function AlbumItem({
album,
@@ -17,70 +17,72 @@ export function AlbumItem({
onDragStart,
onDragEnd,
}: {
album: Album;
manageMode?: boolean;
selectedForManage?: boolean;
onToggleManage?: () => void;
reorderable?: boolean;
onDragStart?: () => void;
onDragEnd?: () => void;
album: Album
manageMode?: boolean
selectedForManage?: boolean
onToggleManage?: () => void
reorderable?: boolean
onDragStart?: () => void
onDragEnd?: () => void
}) {
const dragControls = useDragControls();
const viewAlbum = useGalleryStore((state) => state.viewAlbum);
const renameAlbum = useGalleryStore((state) => state.renameAlbum);
const deleteAlbum = useGalleryStore((state) => state.deleteAlbum);
const activeView = useGalleryStore((state) => state.activeView);
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
const selected = !manageMode && activeView === "album" && selectedAlbumId === album.id;
const dragControls = useDragControls()
const viewAlbum = useGalleryStore((state) => state.viewAlbum)
const renameAlbum = useGalleryStore((state) => state.renameAlbum)
const deleteAlbum = useGalleryStore((state) => state.deleteAlbum)
const activeView = useGalleryStore((state) => state.activeView)
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId)
const selected = !manageMode && activeView === 'album' && selectedAlbumId === album.id
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [renaming, setRenaming] = useState(false);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
const [renaming, setRenaming] = useState(false)
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
const cover = mediaSrc(album.cover_thumbnail_path);
const cover = mediaSrc(album.cover_thumbnail_path)
const row = (
<div
role={manageMode ? "checkbox" : "button"}
role={manageMode ? 'checkbox' : 'button'}
tabIndex={renaming ? -1 : 0}
aria-checked={manageMode ? selectedForManage : 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 ${
aria-current={!manageMode && selected ? 'page' : undefined}
className={`group relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 transition-all duration-150 ${
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
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
? 'bg-white/8 text-white'
: 'text-gray-500 hover:bg-white/5 hover:text-gray-200'
}`}
onClick={() => {
if (manageMode) {
onToggleManage?.();
onToggleManage?.()
} else if (!renaming) {
viewAlbum(album.id);
viewAlbum(album.id)
}
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (renaming || (e.key !== "Enter" && e.key !== " ")) return;
e.preventDefault();
if (e.target !== e.currentTarget) return
if (renaming || (e.key !== 'Enter' && e.key !== ' ')) return
e.preventDefault()
if (manageMode) {
onToggleManage?.();
onToggleManage?.()
} else {
viewAlbum(album.id);
viewAlbum(album.id)
}
}}
onContextMenu={(e) => {
if (manageMode) return;
e.preventDefault();
e.stopPropagation();
setMenu({ x: e.clientX, y: e.clientY });
if (manageMode) return
e.preventDefault()
e.stopPropagation()
setMenu({ x: e.clientX, y: e.clientY })
}}
>
{/* Manage-mode selection checkbox */}
{manageMode ? (
<div
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} />
@@ -93,18 +95,21 @@ export function AlbumItem({
<button
type="button"
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) => {
e.stopPropagation();
onDragStart?.();
dragControls.start(e);
e.stopPropagation()
onDragStart?.()
dragControls.start(e)
}}
onClick={(e) => e.stopPropagation()}
>
<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="6" r="1" /><circle cx="9" cy="6" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
<circle cx="3" cy="3" r="1" />
<circle cx="9" cy="3" r="1" />
<circle cx="3" cy="6" r="1" />
<circle cx="9" cy="6" r="1" />
<circle cx="3" cy="9" r="1" />
<circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip>
@@ -129,16 +134,21 @@ export function AlbumItem({
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}
</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>
{!renaming && confirmingRemoval ? (
<InlineConfirm
onConfirm={() => { void deleteAlbum(album.id); setConfirmingRemoval(false); }}
onConfirm={() => {
void deleteAlbum(album.id)
setConfirmingRemoval(false)
}}
onCancel={() => setConfirmingRemoval(false)}
/>
) : null}
@@ -151,7 +161,7 @@ export function AlbumItem({
</ContextMenu>
) : null}
</div>
);
)
if (reorderable) {
return (
@@ -164,13 +174,11 @@ export function AlbumItem({
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
transition={{ layout: { type: 'spring', stiffness: 520, damping: 38, mass: 0.55 } }}
>
{row}
</Reorder.Item>
);
)
}
return row;
return row
}
+81 -70
View File
@@ -1,82 +1,83 @@
import { useRef, useState } from "react";
import { Reorder } from "framer-motion";
import { useGalleryStore } from "../../store";
import { Tooltip } from "../Tooltip";
import { PlusIcon } from "../icons";
import { AlbumItem } from "./AlbumItem";
import { useAlbumOrdering } from "./useAlbumOrdering";
import { useRef, useState } from 'react'
import { Reorder } from 'framer-motion'
import { useGalleryStore } from '../../store'
import { Tooltip } from '../Tooltip'
import { PlusIcon } from '../icons'
import { AlbumItem } from './AlbumItem'
import { useAlbumOrdering } from './useAlbumOrdering'
export function AlbumSection() {
const albums = useGalleryStore((state) => state.albums);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const deleteAlbums = useGalleryStore((state) => state.deleteAlbums);
const reorderAlbums = useGalleryStore((state) => state.reorderAlbums);
const [creatingAlbum, setCreatingAlbum] = useState(false);
const [createAlbumPending, setCreateAlbumPending] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const newAlbumInputRef = useRef<HTMLInputElement>(null);
const [manageAlbums, setManageAlbums] = useState(false);
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(new Set());
const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false);
const { finishAlbumReorder, handleAlbumReorder, orderedAlbums, setDraggingAlbum } = useAlbumOrdering(albums, reorderAlbums);
const albums = useGalleryStore((state) => state.albums)
const createAlbum = useGalleryStore((state) => state.createAlbum)
const deleteAlbums = useGalleryStore((state) => state.deleteAlbums)
const reorderAlbums = useGalleryStore((state) => state.reorderAlbums)
const [creatingAlbum, setCreatingAlbum] = useState(false)
const [createAlbumPending, setCreateAlbumPending] = useState(false)
const [newAlbumName, setNewAlbumName] = useState('')
const newAlbumInputRef = useRef<HTMLInputElement>(null)
const [manageAlbums, setManageAlbums] = useState(false)
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(new Set())
const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false)
const { finishAlbumReorder, handleAlbumReorder, orderedAlbums, setDraggingAlbum } =
useAlbumOrdering(albums, reorderAlbums)
const startCreatingAlbum = () => {
setCreatingAlbum(true);
setNewAlbumName("");
setTimeout(() => newAlbumInputRef.current?.focus(), 0);
};
setCreatingAlbum(true)
setNewAlbumName('')
setTimeout(() => newAlbumInputRef.current?.focus(), 0)
}
const handleCreateAlbum = async () => {
const name = newAlbumName.trim();
const name = newAlbumName.trim()
if (!name) {
setCreatingAlbum(false);
return;
setCreatingAlbum(false)
return
}
if (createAlbumPending) return;
setCreateAlbumPending(true);
if (createAlbumPending) return
setCreateAlbumPending(true)
try {
const album = await createAlbum(name);
setNewAlbumName("");
setCreatingAlbum(false);
useGalleryStore.getState().viewAlbum(album.id);
const album = await createAlbum(name)
setNewAlbumName('')
setCreatingAlbum(false)
useGalleryStore.getState().viewAlbum(album.id)
} finally {
setCreateAlbumPending(false);
setCreateAlbumPending(false)
}
}
};
const exitManageAlbums = () => {
setManageAlbums(false);
setManageSelectedIds(new Set());
setConfirmingAlbumDelete(false);
};
setManageAlbums(false)
setManageSelectedIds(new Set())
setConfirmingAlbumDelete(false)
}
const toggleManageSelected = (albumId: number) => {
setManageSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(albumId)) next.delete(albumId);
else next.add(albumId);
return next;
});
setConfirmingAlbumDelete(false);
};
const next = new Set(prev)
if (next.has(albumId)) next.delete(albumId)
else next.add(albumId)
return next
})
setConfirmingAlbumDelete(false)
}
const handleDeleteSelectedAlbums = async () => {
const ids = Array.from(manageSelectedIds);
if (ids.length === 0) return;
await deleteAlbums(ids);
exitManageAlbums();
};
const ids = Array.from(manageSelectedIds)
if (ids.length === 0) return
await deleteAlbums(ids)
exitManageAlbums()
}
return (
<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">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">
{manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"}
<span className="text-[10px] font-semibold tracking-[0.15em] text-gray-600 uppercase">
{manageAlbums ? `${manageSelectedIds.size} selected` : 'Albums'}
</span>
{manageAlbums ? (
<button
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
</button>
@@ -88,9 +89,18 @@ export function AlbumSection() {
onClick={() => setManageAlbums(true)}
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">
<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
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
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>
@@ -112,8 +122,9 @@ export function AlbumSection() {
{confirmingAlbumDelete ? (
<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">
Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? "" : "s"}? Your images stay in
the library only the album{manageSelectedIds.size === 1 ? "" : "s"} {manageSelectedIds.size === 1 ? "is" : "are"} removed.
Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? '' : 's'}? Your
images stay in the library only the album{manageSelectedIds.size === 1 ? '' : 's'}{' '}
{manageSelectedIds.size === 1 ? 'is' : 'are'} removed.
</p>
<div className="flex justify-end gap-1.5">
<button
@@ -136,45 +147,45 @@ export function AlbumSection() {
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={() =>
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
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)}
disabled={manageSelectedIds.size === 0}
>
Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ""}
Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ''}
</button>
</div>
)}
</div>
) : 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 ? (
<form
className="flex gap-1 px-1 py-1"
onSubmit={(e) => {
e.preventDefault();
void handleCreateAlbum();
e.preventDefault()
void handleCreateAlbum()
}}
>
<input
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…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
disabled={createAlbumPending}
onKeyDown={(e) => {
if (createAlbumPending) return;
if (e.key === "Escape") {
setCreatingAlbum(false);
setNewAlbumName("");
if (createAlbumPending) return
if (e.key === 'Escape') {
setCreatingAlbum(false)
setNewAlbumName('')
}
}}
onBlur={() => void handleCreateAlbum()}
@@ -217,5 +228,5 @@ export function AlbumSection() {
)}
</div>
</div>
);
)
}
+148 -97
View File
@@ -1,12 +1,12 @@
import { useState, type MouseEvent } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, type Folder, type IndexProgress } from "../../store";
import { ContextMenu, MenuItem, MenuSeparator } from "../menu";
import { InlineConfirm } from "../InlineConfirm";
import { InlineRename } from "../InlineRename";
import { Tooltip } from "../Tooltip";
import { CloseIcon, FolderIcon } from "../icons";
import { useState, type MouseEvent } from 'react'
import { Reorder, useDragControls } from 'framer-motion'
import { open } from '@tauri-apps/plugin-dialog'
import { useGalleryStore, type Folder, type IndexProgress } from '../../store'
import { ContextMenu, MenuItem, MenuSeparator } from '../menu'
import { InlineConfirm } from '../InlineConfirm'
import { InlineRename } from '../InlineRename'
import { Tooltip } from '../Tooltip'
import { CloseIcon, FolderIcon } from '../icons'
export function FolderItem({
folder,
@@ -18,64 +18,77 @@ export function FolderItem({
onDragEnd,
onKeyboardMove,
}: {
folder: Folder;
selected: boolean;
progress: IndexProgress | undefined;
customOrdering: boolean;
dragging: boolean;
onDragStart: (pointerY: number) => void;
onDragEnd: () => void;
onKeyboardMove: (direction: -1 | 1) => void;
folder: Folder
selected: boolean
progress: IndexProgress | undefined
customOrdering: boolean
dragging: boolean
onDragStart: (pointerY: number) => void
onDragEnd: () => void
onKeyboardMove: (direction: -1 | 1) => void
}) {
const dragControls = useDragControls();
const { selectFolder, removeFolder, reindexFolder, 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);
const dragControls = useDragControls()
const {
selectFolder,
removeFolder,
reindexFolder,
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.
const isPausedAll = !!folderWorkers &&
folderWorkers.thumbnail && folderWorkers.metadata && folderWorkers.embedding && folderWorkers.tagging;
const isIndexing = progress && !progress.done;
const isMissing = !!folder.scan_error && !isIndexing;
const isPausedAll =
!!folderWorkers &&
folderWorkers.thumbnail &&
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 [renaming, setRenaming] = useState(false);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const [renaming, setRenaming] = useState(false)
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY });
};
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY })
}
const handleLocateFolder = async () => {
const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` });
if (picked && typeof picked === "string") {
await updateFolderPath(folder.id, picked);
const picked = await open({
directory: true,
multiple: false,
title: `Locate "${folder.name}"`,
})
if (picked && typeof picked === 'string') {
await updateFolderPath(folder.id, picked)
}
}
};
return (
<Reorder.Item
as="div"
value={folder.id}
drag={customOrdering ? "y" : false}
drag={customOrdering ? 'y' : false}
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? "z-20" : ""}`}
style={{ position: "relative" }}
transition={{ layout: { type: 'spring', stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? 'z-20' : ''}`}
style={{ position: 'relative' }}
>
<div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected
? "bg-white/8 text-white"
: "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" : ""}`}
className={`group relative flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 transition-all duration-150 ${
selected ? 'bg-white/8 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-gray-200'
} ${dragging ? 'scale-[1.02] bg-white/[0.11] text-white shadow-xl ring-1 shadow-black/25 ring-white/15' : ''}`}
onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu}
>
@@ -86,41 +99,47 @@ export function FolderItem({
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 ${
dragging
? "cursor-grabbing bg-white/10 text-gray-300"
: "cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400"
? 'cursor-grabbing bg-white/10 text-gray-300'
: 'cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400'
}`}
onPointerDown={(event) => {
event.stopPropagation();
onDragStart(event.clientY);
dragControls.start(event);
event.stopPropagation()
onDragStart(event.clientY)
dragControls.start(event)
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
event.stopPropagation();
onKeyboardMove(event.key === "ArrowUp" ? -1 : 1);
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
event.preventDefault()
event.stopPropagation()
onKeyboardMove(event.key === 'ArrowUp' ? -1 : 1)
}}
>
<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="9" r="1" /><circle cx="9" cy="9" r="1" />
<circle cx="3" cy="3" r="1" />
<circle cx="9" cy="3" r="1" />
<circle cx="3" cy="9" r="1" />
<circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip>
) : null}
{isMissing ? (
<span className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path 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 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="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>
</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 ? (
<InlineRename
name={folder.name}
@@ -128,74 +147,101 @@ export function FolderItem({
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}
</div>
)}
{isIndexing ? (
<>
<div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div>
<div className="h-px bg-white/10 rounded mt-1.5 overflow-hidden">
<div className="mt-0.5 text-[11px] text-gray-600">
{progress.indexed}/{progress.total}
</div>
<div className="mt-1.5 h-px overflow-hidden rounded bg-white/10">
<div
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 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>
{/* Hover action buttons */}
{!renaming && (
confirmingRemoval ? (
{!renaming &&
(confirmingRemoval ? (
<InlineConfirm
onConfirm={() => { void removeFolder(folder.id); setConfirmingRemoval(false); }}
onConfirm={() => {
void removeFolder(folder.id)
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>
<button
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors"
onClick={(e) => { e.stopPropagation(); void reindexFolder(folder.id); }}
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)
}}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path 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 className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
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>
</button>
</Tooltip>
<Tooltip label="Remove from app" anchorToCursor>
<button
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
className="rounded p-1 text-gray-600 transition-colors hover:bg-red-500/10 hover:text-red-400"
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>
</Tooltip>
</div>
)
)}
))}
</div>
{isMissing && (
<div className="mx-2 mb-1 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<p className="text-[11px] text-amber-400 font-medium mb-1.5">Folder not found</p>
<p className="text-[10px] text-gray-500 mb-2 leading-snug">
This folder may have been moved or renamed. Locate it to resume, or remove it from the app.
<div className="mx-2 mb-1 rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2">
<p className="mb-1.5 text-[11px] font-medium text-amber-400">Folder not found</p>
<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.
</p>
<div className="flex gap-1.5">
<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"
onClick={(e) => { e.stopPropagation(); void handleLocateFolder(); }}
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()
}}
>
Locate Folder
</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"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
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)
}}
>
Remove
</button>
@@ -204,24 +250,29 @@ export function FolderItem({
)}
{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="Rename" onSelect={() => setRenaming(true)} />
<MenuItem
label={isPausedAll ? "Resume background work" : "Pause background work"}
label={isPausedAll ? 'Resume background work' : 'Pause background work'}
onSelect={() => setAllWorkersPaused(folder.id, !isPausedAll)}
/>
<MenuItem
label={isMuted ? "Unmute notifications" : "Mute notifications"}
label={isMuted ? 'Unmute notifications' : 'Mute notifications'}
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 />
<MenuItem label="Remove from app" danger onSelect={() => setConfirmingRemoval(true)} />
</ContextMenu>
)}
</Reorder.Item>
);
)
}
+21 -19
View File
@@ -1,14 +1,14 @@
import { Reorder } from "framer-motion";
import { useGalleryStore } from "../../store";
import { Dropdown } from "../menu";
import { FolderItem } from "./FolderItem";
import { useFolderOrdering } from "./useFolderOrdering";
import { Reorder } from 'framer-motion'
import { useGalleryStore } from '../../store'
import { Dropdown } from '../menu'
import { FolderItem } from './FolderItem'
import { useFolderOrdering } from './useFolderOrdering'
export function LibrarySection() {
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const reorderFolders = useGalleryStore((state) => state.reorderFolders);
const folders = useGalleryStore((state) => state.folders)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const indexingProgress = useGalleryStore((state) => state.indexingProgress)
const reorderFolders = useGalleryStore((state) => state.reorderFolders)
const {
customOrdering,
@@ -22,13 +22,15 @@ export function LibrarySection() {
pointerYRef,
setDraggedFolderId,
setLibrarySort,
} = useFolderOrdering(folders, reorderFolders);
} = useFolderOrdering(folders, reorderFolders)
return (
<>
{folders.length > 0 && (
<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
value={librarySort}
onChange={setLibrarySort}
@@ -36,9 +38,9 @@ export function LibrarySection() {
trigger="compact"
panelClassName="min-w-0"
options={[
{ value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" },
{ value: "custom", label: "Custom" },
{ value: 'az', label: 'A-Z' },
{ value: 'za', label: 'Z-A' },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
@@ -51,10 +53,10 @@ export function LibrarySection() {
values={displayedFolders.map((folder) => folder.id)}
onReorder={customOrdering ? handleReorder : () => {}}
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 ? (
<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
</p>
) : (
@@ -67,8 +69,8 @@ export function LibrarySection() {
customOrdering={customOrdering}
dragging={draggedFolderId === folder.id}
onDragStart={(pointerY) => {
pointerYRef.current = pointerY;
setDraggedFolderId(folder.id);
pointerYRef.current = pointerY
setDraggedFolderId(folder.id)
}}
onDragEnd={finishReorder}
onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)}
@@ -77,5 +79,5 @@ export function LibrarySection() {
)}
</Reorder.Group>
</>
);
)
}

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