feat: runtime feature flags, upload/download, RAW decoding

Adds Redis-backed feature flags for vision stages with admin UI toggles
and manual backfill trigger, photo upload and download routers with
frontend upload modal, and rawpy-based RAW decoding with JPEG fallback
for misnamed DNGs. Fixes pgvector serialization, is_trashed filter, and
naive-datetime bind in incremental duplicate regrouping; bumps Celery
time limits on regroup tasks beyond the 5-minute default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-14 21:31:52 +02:00
parent 800ee447ad
commit 5c531f11da
16 changed files with 2232 additions and 35 deletions

View File

@@ -15,15 +15,23 @@ import {
Activity,
FolderSearch,
Shield,
Brain,
ScanText,
UserSquare2,
Boxes,
Tags as TagsIcon,
RotateCcw,
} from 'lucide-react'
import clsx from 'clsx'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
library,
admin as adminApi,
type MediaType,
type PipelineStage,
type ScanStatus,
type WorkerStatus,
type FeatureFlagSnapshot,
} from '../../services/api'
import { toast } from '../ToastContainer'
import { useAuth } from '../../contexts/AuthContext'
@@ -42,13 +50,16 @@ const SETTINGS_SCAN_STATUS_KEY = ['settings', 'scan-status'] as const
// the grid renders from. Imported via the canonical hook key.
import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery'
type SettingsTab = 'library' | 'users'
type SettingsTab = 'library' | 'ai' | 'users'
const TABS: { id: SettingsTab; label: string; adminOnly?: boolean }[] = [
{ id: 'library', label: 'Library Management' },
{ id: 'ai', label: 'AI Features', adminOnly: true },
{ id: 'users', label: 'Users', adminOnly: true },
]
const SETTINGS_FEATURE_FLAGS_KEY = ['settings', 'feature-flags'] as const
/**
* Full-page settings view with tabbed navigation. Replaces the old
* modal dialog — renders as a top-level section in the main content
@@ -842,6 +853,13 @@ export function SettingsPage() {
</Section>
</>)}
{activeTab === 'ai' && isAdmin && (
<AiFeaturesTab
busy={busy}
runAction={runAction}
/>
)}
{activeTab === 'users' && isAdmin && (
<Section
icon={<Shield className="h-4 w-4" />}
@@ -1073,3 +1091,267 @@ function ActionButton({
</button>
)
}
// ---------------------------------------------------------------------------
// AI Features admin tab
// ---------------------------------------------------------------------------
interface AiFeaturesTabProps {
busy: Record<string, boolean>
runAction: <T>(
key: string,
fn: () => Promise<T>,
successTitle: string,
describe?: (result: T) => string | undefined,
) => Promise<void>
}
// Flags are keyed by the backend's canonical name ("vision.enabled",
// "vision.ocr.enabled", ...). The metadata here just adds presentation
// (label, short description, icon) so the tab layout stays data-driven.
const FLAG_META: Array<{
id: string
label: string
description: string
icon: React.ReactNode
// Optional "run this backfill" hook — lets the user kick off a stage's
// backfill right from the toggle row without hopping to a separate UI.
backfillTask?: 'embed' | 'ocr' | 'detect' | 'faces' | 'classify'
}> = [
{
id: 'vision.enabled',
label: 'Vision pipeline (master switch)',
description:
'When off, every AI stage below is skipped — including newly uploaded photos. ' +
'Existing results stay intact.',
icon: <Sparkles className="h-3.5 w-3.5" />,
},
{
id: 'vision.ocr.enabled',
label: 'Text recognition (OCR)',
description: 'Extract printed / handwritten text from photos so it becomes searchable.',
icon: <ScanText className="h-3.5 w-3.5" />,
backfillTask: 'ocr',
},
{
id: 'vision.detector.enabled',
label: 'Object detection',
description: 'Tag photos with detected objects (person, car, dog, …) via YOLOv8n.',
icon: <Boxes className="h-3.5 w-3.5" />,
backfillTask: 'detect',
},
{
id: 'vision.faces.enabled',
label: 'Face recognition',
description:
'Find and cluster faces across the library (RetinaFace + ArcFace). ' +
'Expensive on big libraries — disable if you don\'t need the People view.',
icon: <UserSquare2 className="h-3.5 w-3.5" />,
backfillTask: 'faces',
},
{
id: 'vision.classifier.enabled',
label: 'Content classification',
description: 'Zero-shot CLIP tags for scenes / activities (beach, wedding, …).',
icon: <TagsIcon className="h-3.5 w-3.5" />,
backfillTask: 'classify',
},
]
function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
const queryClient = useQueryClient()
const flagsQuery = useQuery<{ flags: FeatureFlagSnapshot }>({
queryKey: SETTINGS_FEATURE_FLAGS_KEY,
queryFn: adminApi.listFeatureFlags,
staleTime: 5_000,
})
const flags = flagsQuery.data?.flags ?? {}
const masterOff = flags['vision.enabled'] && !flags['vision.enabled'].effective
const applyFlag = async (name: string, value: boolean | null) => {
await runAction(
`flag:${name}`,
() => adminApi.setFeatureFlag(name, value),
value === null ? 'Override cleared' : `Feature ${value ? 'enabled' : 'disabled'}`,
)
queryClient.invalidateQueries({ queryKey: SETTINGS_FEATURE_FLAGS_KEY })
// Non-admin feature map drives sidebar gating — invalidate so the
// People / Tags entries appear / disappear immediately without a
// page reload.
queryClient.invalidateQueries({ queryKey: ['features'] })
}
type BackfillTask = 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null
const runBackfill = (task: BackfillTask) =>
runAction(
`ai-backfill:${task ?? 'all'}`,
() => adminApi.triggerAiBackfill({ task }),
task ? `Backfill queued for ${task}` : 'Full backfill queued',
(r) => `Celery task ${r.task_id}`,
)
return (
<>
<Section icon={<Brain className="h-4 w-4" />} title="AI feature flags">
<p className="text-xs text-text-muted">
Toggle each stage at runtime. Changes are observed by Celery
workers on the next task no restart needed. "Default" means
the flag hasn\'t been overridden and is tracking the YAML config;
an overridden flag is pinned to the value shown until cleared.
</p>
{flagsQuery.isLoading && (
<div className="mt-3 flex items-center gap-2 text-xs text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
Loading feature flags…
</div>
)}
{flagsQuery.error && (
<ErrorBanner
title="Could not load feature flags"
detail={String((flagsQuery.error as Error).message || flagsQuery.error)}
/>
)}
{!flagsQuery.isLoading && !flagsQuery.error && (
<div className="mt-3 space-y-2">
{FLAG_META.map((meta) => {
const state = flags[meta.id]
if (!state) return null
const busyKey = `flag:${meta.id}`
const isBusy = !!busy[busyKey]
const isMaster = meta.id === 'vision.enabled'
const dimmed = !isMaster && masterOff
return (
<div
key={meta.id}
className={clsx(
'rounded border border-border bg-surface p-3 text-xs',
dimmed && 'opacity-60',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-text">
{meta.icon}
<span className="font-medium">{meta.label}</span>
{state.overridden && (
<span className="rounded bg-primary/20 px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary">
overridden
</span>
)}
</div>
<p className="mt-1 text-[11px] text-text-muted">{meta.description}</p>
<p className="mt-1 text-[10px] text-text-faint">
Default: {state.default ? 'on' : 'off'} · Currently:{' '}
<span className={state.effective ? 'text-pick' : 'text-reject'}>
{state.effective ? 'on' : 'off'}
</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
role="switch"
aria-checked={state.effective}
onClick={() => applyFlag(meta.id, !state.effective)}
disabled={isBusy || (dimmed && !isMaster)}
className={clsx(
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors',
state.effective ? 'bg-primary' : 'bg-surface-2 border border-border',
(isBusy || (dimmed && !isMaster)) && 'cursor-not-allowed opacity-50',
)}
title={state.effective ? 'Click to disable' : 'Click to enable'}
>
<span
aria-hidden="true"
className={clsx(
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform',
state.effective ? 'translate-x-[22px]' : 'translate-x-0.5',
)}
/>
</button>
{state.overridden && (
<button
onClick={() => applyFlag(meta.id, null)}
disabled={isBusy}
className="rounded border border-border p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
title="Reset to YAML default"
aria-label="Reset override"
>
<RotateCcw className="h-3 w-3" />
</button>
)}
</div>
</div>
{meta.backfillTask && state.effective && !masterOff && (
<div className="mt-2">
<ActionButton
loading={!!busy[`ai-backfill:${meta.backfillTask}`]}
onClick={() => runBackfill(meta.backfillTask!)}
>
<RefreshCw className="h-3.5 w-3.5" />
Run {meta.backfillTask} backfill
</ActionButton>
</div>
)}
</div>
)
})}
</div>
)}
</Section>
<Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers">
<p className="text-xs text-text-muted">
Run a full pass across the enabled stages, recompute face
clusters, or force a fresh filesystem scan. All three are safe
to run repeatedly — the backfill only touches photos that
don\'t yet have a given output, and the rescan skips files
that are already indexed.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<ActionButton
loading={!!busy['ai-backfill:all']}
onClick={() => runBackfill(null)}
disabled={masterOff}
>
<Sparkles className="h-4 w-4" />
Run full vision backfill
</ActionButton>
<ActionButton
loading={!!busy['recluster']}
onClick={() =>
runAction(
'recluster',
() => adminApi.triggerFaceRecluster(),
'Face recluster queued',
(r) => `Celery task ${r.task_id}`,
)
}
disabled={masterOff || !flags['vision.faces.enabled']?.effective}
>
<UserSquare2 className="h-4 w-4" />
Recluster faces
</ActionButton>
<ActionButton
loading={!!busy['rescan-full']}
onClick={() =>
runAction(
'rescan-full',
() => adminApi.triggerFullRescan(),
'Rescan queued',
(r) => `Celery task ${r.task_id}`,
)
}
>
<RefreshCw className="h-4 w-4" />
Rescan all source roots
</ActionButton>
</div>
</Section>
</>
)
}

View File

@@ -11,12 +11,13 @@ import {
Copy,
Trash2,
Users,
Download as DownloadIcon,
} from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useSharedHeapsQuery } from '../../hooks/useSharingQueries'
import { heaps as heapsApi, type Heap } from '../../services/api'
import { heaps as heapsApi, downloads, type Heap } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
@@ -448,6 +449,14 @@ export function HeapsPanel() {
setSharingHeap(heap)
}}
/>
<MenuItem
icon={<DownloadIcon className="h-3.5 w-3.5" />}
label="Download as zip"
onClick={() => {
setOpenMenuId(null)
downloads.trigger(downloads.heapUrl(heap.id))
}}
/>
<div className="my-1 h-px bg-border" />
<MenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}

View File

@@ -24,9 +24,11 @@ import {
LogOut,
Shield,
Clock,
Upload as UploadIcon,
Download as DownloadIcon,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
import { sourceFolders, photos as photosApi, downloads, type FolderTreeNode } from '../../services/api'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore'
@@ -45,8 +47,10 @@ import { registerUndoable } from '../../store/undoStore'
import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
import { ShareDialog } from '../sharing/ShareDialog'
import { UploadModal } from '../upload/UploadModal'
import { useSharedFoldersQuery } from '../../hooks/useSharingQueries'
import { useAuth } from '../../contexts/AuthContext'
import { useFeaturesQuery } from '../../hooks/useFeaturesQuery'
interface TreeItem {
id: string
@@ -78,6 +82,15 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
const { data: allTags = [] } = useTagsQuery()
const { data: faceClusters = [] } = useTagsQuery('face_cluster')
const { data: stats } = useLibraryStatsQuery()
const { data: featuresMap } = useFeaturesQuery()
const visionOn = featuresMap ? featuresMap['vision.enabled'] !== false : true
const facesOn = visionOn && (featuresMap ? featuresMap['vision.faces.enabled'] !== false : true)
const tagsOn =
visionOn &&
(featuresMap
? featuresMap['vision.detector.enabled'] !== false ||
featuresMap['vision.classifier.enabled'] !== false
: true)
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..."
@@ -117,6 +130,14 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
id: string
name: string
} | null>(null)
// Upload modal state. `uploadTarget` stores the pre-selected
// destination Folder/SourceRoot id so "Upload here…" on a folder row
// drops files straight into that folder; null means the general
// Library-level button (defaults to the first source root).
const [uploadTarget, setUploadTarget] = useState<{ open: boolean; folderId: string | null }>({
open: false,
folderId: null,
})
const { data: sharedFolders = [] } = useSharedFoldersQuery()
// Bulk discard mutation for the drag-onto-Discarded interaction.
@@ -414,8 +435,8 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ id: 'people', label: 'People', icon: <Users className="h-4 w-4" />, count: peopleTotalCount },
...(tagsOn ? [{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount }] : []),
...(facesOn ? [{ id: 'people', label: 'People', icon: <Users className="h-4 w-4" />, count: peopleTotalCount }] : []),
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
{ id: 'memories', label: 'Memories', icon: <Clock className="h-4 w-4" /> },
@@ -670,6 +691,14 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
onClick={(e) => e.stopPropagation()}
className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
>
<FolderMenuItem
icon={<UploadIcon className="h-3.5 w-3.5" />}
label="Upload here…"
onClick={() => {
setOpenMenuId(null)
setUploadTarget({ open: true, folderId })
}}
/>
<FolderMenuItem
icon={<FolderPlus className="h-3.5 w-3.5" />}
label="New sub-folder"
@@ -718,6 +747,14 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
setSharingFolder({ id: folderId, name: item.label })
}}
/>
<FolderMenuItem
icon={<DownloadIcon className="h-3.5 w-3.5" />}
label="Download as zip"
onClick={() => {
setOpenMenuId(null)
downloads.trigger(downloads.folderUrl(folderId))
}}
/>
<div className="my-1 h-px bg-border" />
<FolderMenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
@@ -801,14 +838,24 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
Library
</h2>
<button
onClick={onCollapse}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Collapse panel (Tab)"
aria-label="Collapse panel"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
<div className="flex items-center gap-1">
<button
onClick={() => setUploadTarget({ open: true, folderId: null })}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Upload photos"
aria-label="Upload photos"
>
<UploadIcon className="h-3.5 w-3.5" />
</button>
<button
onClick={onCollapse}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Collapse panel (Tab)"
aria-label="Collapse panel"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Active heap card — pinned just below the Library header so
* toasts (bottom-left fixed) can't cover it. Returns null when
@@ -922,6 +969,11 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
targetName={sharingFolder?.name ?? ''}
onClose={() => setSharingFolder(null)}
/>
<UploadModal
isOpen={uploadTarget.open}
initialFolderId={uploadTarget.folderId}
onClose={() => setUploadTarget({ open: false, folderId: null })}
/>
</div>
)
}

View File

@@ -0,0 +1,608 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronDown,
ChevronRight,
Folder as FolderIcon,
Upload as UploadIcon,
X,
FileImage,
CheckCircle2,
AlertCircle,
} from 'lucide-react'
import clsx from 'clsx'
import { useQueryClient } from '@tanstack/react-query'
import { uploads, type FolderTreeNode } from '../../services/api'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { FOLDER_TREE_QUERY_KEY } from '../../hooks/useFolderTreeQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { toast } from '../ToastContainer'
interface UploadModalProps {
isOpen: boolean
onClose: () => void
/** Optional pre-selected destination. Accepts a Folder id or SourceRoot
* id — the backend resolves source roots to their root Folder row. */
initialFolderId?: string | null
}
interface QueuedFile {
/** Stable key; the browser may give us multiple files with the same
* name from different subfolders, so we key on index + path. */
key: string
file: File
relativePath: string
status: 'pending' | 'uploading' | 'done' | 'error'
progress: number
error?: string
}
const SUPPORTED_EXTENSIONS = new Set([
'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.bmp',
'.cr2', '.cr3', '.nef', '.arw', '.raf', '.dng', '.orf', '.rw2', '.pef', '.srw',
'.heic', '.heif',
'.mp4', '.mov', '.avi', '.mkv', '.mts', '.m2ts', '.3gp', '.wmv', '.flv',
])
function extOf(name: string): string {
const i = name.lastIndexOf('.')
return i === -1 ? '' : name.slice(i).toLowerCase()
}
function isSupported(name: string): boolean {
return SUPPORTED_EXTENSIONS.has(extOf(name))
}
const MAX_PARALLEL = 4
/** Walk the tree to collect the ids on the path from root to `targetId`,
* excluding the target itself — used to expand ancestor rows so a
* pre-selected destination is visible. */
function collectAncestors(tree: FolderTreeNode[], targetId: string): string[] {
const path: string[] = []
const walk = (nodes: FolderTreeNode[], chain: string[]): boolean => {
for (const n of nodes) {
if (n.id === targetId) {
path.push(...chain)
return true
}
if (n.children && walk(n.children, [...chain, n.id])) return true
}
return false
}
walk(tree, [])
return path
}
/**
* Upload from desktop. Supports:
* - Dropping files or folders onto the drop zone
* - Picking files with "Select files"
* - Picking a whole folder with "Select folder" (webkitdirectory);
* every file's webkitRelativePath is sent to the backend so sub-
* folder structure is preserved under the chosen destination.
*
* Destination is a Folder row (or a source root, which the backend
* resolves to its root folder). An optional heap can also be chosen —
* uploaded photos are added to that heap in the same request.
*/
export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalProps) {
const queryClient = useQueryClient()
const { data: folderTree } = useFolderTreeQuery()
const { data: allHeaps = [] } = useHeapsQuery()
const [queue, setQueue] = useState<QueuedFile[]>([])
const [destFolderId, setDestFolderId] = useState<string | null>(null)
const [destHeapId, setDestHeapId] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
const dirInputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
// Default destination: caller-provided initialFolderId wins; otherwise
// first source root in the tree. Re-runs when the modal is re-opened
// with a different initial target so the right row is highlighted.
useEffect(() => {
if (!isOpen) return
if (initialFolderId) {
setDestFolderId(initialFolderId)
// Expand every ancestor of the pre-selected folder so the row is
// actually visible in the tree.
if (folderTree) {
const ancestors = collectAncestors(folderTree, initialFolderId)
setExpandedFolders((prev) => new Set([...prev, ...ancestors]))
}
return
}
if (!destFolderId && folderTree && folderTree.length > 0) {
setDestFolderId(folderTree[0].id)
setExpandedFolders(new Set([folderTree[0].id]))
}
}, [isOpen, initialFolderId, folderTree, destFolderId])
// Esc closes (unless mid-upload — don't orphan in-flight requests).
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isUploading) onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, isUploading, onClose])
// Reset transient state on open so a previous session's queue doesn't
// bleed into a fresh one.
useEffect(() => {
if (isOpen) {
setQueue([])
setIsUploading(false)
}
}, [isOpen])
const addFiles = (incoming: File[], relPathFn?: (f: File) => string) => {
const next: QueuedFile[] = []
let skipped = 0
for (const file of incoming) {
const relPath = (relPathFn?.(file) ?? '').replace(/\\/g, '/').replace(/^\/+/, '')
const filename = relPath || file.name
if (!isSupported(filename)) {
skipped++
continue
}
next.push({
key: `${relPath || file.name}::${file.size}::${file.lastModified}::${next.length}`,
file,
relativePath: relPath,
status: 'pending',
progress: 0,
})
}
if (skipped > 0) {
toast.info?.(`Skipped ${skipped} unsupported file${skipped === 1 ? '' : 's'}`)
}
setQueue((prev) => [...prev, ...next])
}
const handleFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
addFiles(files, (f) => f.name) // no relative path for single files
e.target.value = '' // allow re-picking the same file
}
const handleDirPick = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
addFiles(files, (f) => (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name)
e.target.value = ''
}
// Drag-and-drop handler. We walk the DataTransferItemList recursively
// with webkitGetAsEntry so dropped folders contribute every nested
// file, with relative paths reconstructed from the entry chain.
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const items = Array.from(e.dataTransfer.items)
const collected: { file: File; relativePath: string }[] = []
const walkEntry = (entry: any, pathPrefix: string): Promise<void> => {
return new Promise((resolve) => {
if (!entry) return resolve()
if (entry.isFile) {
entry.file((f: File) => {
collected.push({
file: f,
relativePath: pathPrefix ? `${pathPrefix}/${entry.name}` : '',
})
resolve()
}, () => resolve())
} else if (entry.isDirectory) {
const reader = entry.createReader()
const readBatch = () => {
reader.readEntries(async (entries: any[]) => {
if (!entries.length) return resolve()
const childPrefix = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name
await Promise.all(entries.map((c) => walkEntry(c, childPrefix)))
// readEntries only returns a batch at a time; loop until empty.
readBatch()
}, () => resolve())
}
readBatch()
} else {
resolve()
}
})
}
await Promise.all(
items.map((it) => {
const entry = (it as DataTransferItem & { webkitGetAsEntry?: () => any }).webkitGetAsEntry?.()
return walkEntry(entry, '')
})
)
if (collected.length === 0) {
// Fallback for browsers without webkitGetAsEntry — use plain files.
const files = Array.from(e.dataTransfer.files)
addFiles(files, (f) => f.name)
return
}
const files = collected.map((c) => c.file)
const pathMap = new Map<File, string>(collected.map((c) => [c.file, c.relativePath]))
addFiles(files, (f) => pathMap.get(f) || f.name)
}
const removeFromQueue = (key: string) => {
setQueue((prev) => prev.filter((q) => q.key !== key))
}
const startUpload = async () => {
if (!destFolderId || queue.length === 0) return
setIsUploading(true)
const ctrl = new AbortController()
abortRef.current = ctrl
// Simple worker-pool: up to MAX_PARALLEL concurrent uploads.
const pending = queue.filter((q) => q.status === 'pending' || q.status === 'error')
let cursor = 0
let successCount = 0
let failCount = 0
const uploadOne = async (item: QueuedFile) => {
setQueue((prev) =>
prev.map((q) => (q.key === item.key ? { ...q, status: 'uploading', progress: 0, error: undefined } : q))
)
try {
await uploads.uploadFile(item.file, destFolderId, {
relativePath: item.relativePath || undefined,
heapId: destHeapId,
signal: ctrl.signal,
onProgress: (loaded, total) => {
const pct = total > 0 ? loaded / total : 0
setQueue((prev) =>
prev.map((q) => (q.key === item.key ? { ...q, progress: pct } : q))
)
},
})
successCount++
setQueue((prev) =>
prev.map((q) => (q.key === item.key ? { ...q, status: 'done', progress: 1 } : q))
)
} catch (err: any) {
failCount++
const msg = err?.response?.data?.detail || err?.message || 'Upload failed'
setQueue((prev) =>
prev.map((q) => (q.key === item.key ? { ...q, status: 'error', error: msg } : q))
)
}
}
const workers: Promise<void>[] = []
for (let i = 0; i < Math.min(MAX_PARALLEL, pending.length); i++) {
workers.push(
(async () => {
while (cursor < pending.length && !ctrl.signal.aborted) {
const idx = cursor++
await uploadOne(pending[idx])
}
})()
)
}
await Promise.all(workers)
setIsUploading(false)
abortRef.current = null
// Refresh everything affected by new photos.
queryClient.invalidateQueries({ queryKey: FOLDER_TREE_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['photos'] })
if (successCount > 0) {
toast.success(
`Uploaded ${successCount} file${successCount === 1 ? '' : 's'}`,
failCount > 0 ? `${failCount} failed — see list` : undefined
)
}
if (successCount === 0 && failCount > 0) {
toast.error('Upload failed', `${failCount} file${failCount === 1 ? '' : 's'} errored`)
}
}
const cancelUpload = () => {
abortRef.current?.abort()
}
const totalBytes = useMemo(() => queue.reduce((s, q) => s + q.file.size, 0), [queue])
const uploadedBytes = useMemo(
() => queue.reduce((s, q) => s + q.file.size * (q.status === 'done' ? 1 : q.progress), 0),
[queue]
)
const overallPct = totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={!isUploading ? onClose : undefined}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 flex max-h-[85vh] w-[760px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<div className="flex items-center gap-2">
<UploadIcon className="h-4 w-4 text-text-muted" />
<h2 className="text-base font-semibold text-text">Upload photos</h2>
</div>
<button
onClick={onClose}
disabled={isUploading}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-40"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden p-5">
{/* Left: destination picker */}
<div className="flex w-64 flex-col gap-4 overflow-hidden">
<div className="flex flex-col gap-2 overflow-hidden">
<label className="text-xs font-medium uppercase tracking-wide text-text-muted">
Destination folder
</label>
<div className="flex-1 overflow-auto rounded border border-border bg-bg p-1 text-sm">
{folderTree && folderTree.length > 0 ? (
folderTree.map((n) => (
<FolderTreeRow
key={n.id}
node={n}
depth={0}
selectedId={destFolderId}
onSelect={setDestFolderId}
expanded={expandedFolders}
onToggle={(id) =>
setExpandedFolders((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
/>
))
) : (
<div className="p-3 text-xs text-text-muted">No folders yet.</div>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-medium uppercase tracking-wide text-text-muted">
Also add to heap (optional)
</label>
<select
value={destHeapId ?? ''}
onChange={(e) => setDestHeapId(e.target.value || null)}
className="rounded border border-border bg-bg px-2 py-1.5 text-sm text-text"
>
<option value=""> none </option>
{allHeaps.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</div>
</div>
{/* Right: drop zone + queue */}
<div className="flex min-w-0 flex-1 flex-col gap-3 overflow-hidden">
<div
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={clsx(
'flex flex-col items-center justify-center rounded border-2 border-dashed px-4 py-6 text-center transition-colors',
dragOver
? 'border-primary bg-primary/10'
: 'border-border bg-bg'
)}
>
<UploadIcon className="mb-2 h-6 w-6 text-text-muted" />
<div className="text-sm text-text">
Drop files or folders here
</div>
<div className="mt-1 text-xs text-text-muted">
Folder structure is preserved under the destination.
</div>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-surface-2"
>
Select files
</button>
<button
type="button"
onClick={() => dirInputRef.current?.click()}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-surface-2"
>
Select folder
</button>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*,.heic,.heif,.cr2,.cr3,.nef,.arw,.raf,.dng,.orf,.rw2,.pef,.srw"
className="hidden"
onChange={handleFilePick}
/>
<input
ref={dirInputRef}
type="file"
multiple
// @ts-expect-error — non-standard but supported in Chromium/WebKit
webkitdirectory=""
directory=""
className="hidden"
onChange={handleDirPick}
/>
</div>
{/* Queue */}
<div className="min-h-0 flex-1 overflow-auto rounded border border-border bg-bg">
{queue.length === 0 ? (
<div className="flex h-full items-center justify-center text-xs text-text-muted">
No files added yet.
</div>
) : (
<ul className="divide-y divide-border">
{queue.map((item) => (
<li key={item.key} className="flex items-center gap-2 px-3 py-2 text-sm">
<FileImage className="h-4 w-4 shrink-0 text-text-muted" />
<div className="min-w-0 flex-1">
<div className="truncate text-text">
{item.relativePath || item.file.name}
</div>
{item.status === 'uploading' && (
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-surface-2">
<div
className="h-full bg-primary transition-all"
style={{ width: `${Math.round(item.progress * 100)}%` }}
/>
</div>
)}
{item.status === 'error' && item.error && (
<div className="mt-0.5 truncate text-xs text-reject">{item.error}</div>
)}
</div>
<div className="shrink-0">
{item.status === 'done' && <CheckCircle2 className="h-4 w-4 text-green-500" />}
{item.status === 'error' && <AlertCircle className="h-4 w-4 text-reject" />}
{item.status !== 'done' && !isUploading && (
<button
onClick={() => removeFromQueue(item.key)}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
aria-label="Remove"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{queue.length > 0 && (
<div className="text-xs text-text-muted">
{queue.length} file{queue.length === 1 ? '' : 's'} {' '}
{(totalBytes / 1024 / 1024).toFixed(1)} MB
{isUploading && `${overallPct}% uploaded`}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<button
onClick={isUploading ? cancelUpload : onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
{isUploading ? 'Cancel' : 'Close'}
</button>
<button
onClick={startUpload}
disabled={isUploading || queue.length === 0 || !destFolderId}
className="flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 text-sm font-medium text-white hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<UploadIcon className="h-3.5 w-3.5" />
{isUploading ? 'Uploading…' : `Upload ${queue.length || ''}`.trim()}
</button>
</div>
</div>
</div>
</div>
)
}
interface FolderTreeRowProps {
node: FolderTreeNode
depth: number
selectedId: string | null
onSelect: (id: string) => void
expanded: Set<string>
onToggle: (id: string) => void
}
function FolderTreeRow({
node,
depth,
selectedId,
onSelect,
expanded,
onToggle,
}: FolderTreeRowProps) {
const isExpanded = expanded.has(node.id)
const hasChildren = node.children && node.children.length > 0
const isSelected = selectedId === node.id
return (
<>
<div
className={clsx(
'flex cursor-pointer items-center gap-1 rounded px-1 py-1 text-sm',
isSelected ? 'bg-primary/20 text-text' : 'text-text-muted hover:bg-surface-2 hover:text-text'
)}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
onClick={() => onSelect(node.id)}
>
<button
onClick={(e) => {
e.stopPropagation()
if (hasChildren) onToggle(node.id)
}}
className="flex h-4 w-4 items-center justify-center"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)
) : null}
</button>
<FolderIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{node.name}</span>
</div>
{isExpanded &&
node.children?.map((c) => (
<FolderTreeRow
key={c.id}
node={c}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
expanded={expanded}
onToggle={onToggle}
/>
))}
</>
)
}

View File

@@ -0,0 +1,39 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { features, type FeaturesMap } from '../services/api'
export const FEATURES_QUERY_KEY = ['features'] as const
/** Read the effective feature-flag state (admin override or YAML
* default). Powers conditional rendering of pipeline-dependent UI —
* People view, Tags view, OCR snippets, etc. */
export function useFeaturesQuery() {
return useQuery<FeaturesMap>({
queryKey: FEATURES_QUERY_KEY,
queryFn: features.list,
// Re-read every minute so admin toggles reflect without a page
// reload. The admin tab also invalidates this key on write so the
// refresh can be immediate for the admin who just flipped it.
staleTime: 60_000,
refetchInterval: 60_000,
})
}
export function useIsFeatureEnabled(
name:
| 'vision.enabled'
| 'vision.ocr.enabled'
| 'vision.detector.enabled'
| 'vision.faces.enabled'
| 'vision.classifier.enabled',
): boolean {
const { data } = useFeaturesQuery()
// Default to enabled while loading so we don't flash "feature off"
// during a first-paint fetch. The backend is the source of truth;
// any gated UI that slipped through just returns empty data anyway.
if (!data) return true
return !!data[name]
}
export function invalidateFeaturesQuery(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: FEATURES_QUERY_KEY })
}

View File

@@ -713,6 +713,72 @@ export const heaps = {
},
}
// Upload API — single file per request so the browser can fan out many
// POSTs in parallel with per-file progress. For folder uploads the
// caller passes each File's webkitRelativePath so the backend can
// materialise the folder structure under the destination.
export const uploads = {
uploadFile: async (
file: File,
destinationFolderId: string,
opts: {
relativePath?: string
heapId?: string | null
onProgress?: (loadedBytes: number, totalBytes: number) => void
signal?: AbortSignal
} = {}
) => {
const form = new FormData()
form.append('file', file)
form.append('destination_folder_id', destinationFolderId)
if (opts.relativePath) form.append('relative_path', opts.relativePath)
if (opts.heapId) form.append('heap_id', opts.heapId)
const response = await api.post('/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
signal: opts.signal,
onUploadProgress: (evt) => {
if (opts.onProgress && evt.total) opts.onProgress(evt.loaded, evt.total)
},
})
return response.data as {
photo_id: string
filename: string
folder_id: string
folder_path: string
heap_id: string | null
}
},
}
// Download helpers — build a URL the browser can pull directly via an
// <a href>. The backend accepts `?token=` so an <a> works without a
// custom fetch + save-blob dance; the Authorization header is not
// settable on a plain link click.
export const downloads = {
folderUrl: (folderId: string): string => {
const token = localStorage.getItem('access_token') || ''
return `${API_BASE_URL}/download/folders/${folderId}?token=${encodeURIComponent(token)}`
},
heapUrl: (heapId: string): string => {
const token = localStorage.getItem('access_token') || ''
return `${API_BASE_URL}/download/heaps/${heapId}?token=${encodeURIComponent(token)}`
},
trigger: (url: string) => {
// Kicking off a download via a transient <a> click keeps the
// browser in charge of the file dialog + progress indicator. We
// use target=_blank so the current SPA route isn't replaced if
// the server returns an error mid-stream.
const a = document.createElement('a')
a.href = url
a.rel = 'noopener'
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
},
}
// Sharing API
export interface SharedHeap {
@@ -928,6 +994,61 @@ export const admin = {
const response = await api.delete(`/admin/users/${userId}`)
return response.data
},
// --- AI / vision feature flags + manual pipeline triggers -----------
listFeatureFlags: async (): Promise<{ flags: FeatureFlagSnapshot }> => {
const response = await api.get('/admin/feature-flags')
return response.data
},
/** Set ``value`` to toggle; pass ``null`` to clear the override and
* fall back to the YAML default. */
setFeatureFlag: async (
name: string,
value: boolean | null,
): Promise<{ flags: FeatureFlagSnapshot }> => {
const response = await api.patch(`/admin/feature-flags/${encodeURIComponent(name)}`, { value })
return response.data
},
triggerAiBackfill: async (body: {
task?: 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null
limit?: number | null
}): Promise<{ status: string; task_id: string }> => {
const response = await api.post('/admin/ai/backfill', body)
return response.data
},
triggerFaceRecluster: async (): Promise<{ status: string; task_id: string }> => {
const response = await api.post('/admin/ai/recluster-faces')
return response.data
},
triggerFullRescan: async (): Promise<{ status: string; task_id: string }> => {
const response = await api.post('/admin/ai/rescan')
return response.data
},
}
export interface FeatureFlagState {
effective: boolean
default: boolean
overridden: boolean
}
export type FeatureFlagSnapshot = Record<string, FeatureFlagState>
export type FeaturesMap = Record<string, boolean>
// Public read of effective feature flags. Available to any signed-in
// user so the frontend can hide sections that depend on a disabled
// pipeline stage (e.g. People when faces are off).
export const features = {
list: async (): Promise<FeaturesMap> => {
const response = await api.get('/features')
return response.data
},
}
export default api