refactor: strip AI pipeline to binary photo/other classifier

Drops face recognition, OCR, object detection, and semantic embeddings.
The sole remaining vision task is a CLIP-based binary classifier
(photography vs other); photos in "other" get needs_review=true so
screenshots, documents, memes and scans can be triaged from a new
filter pill in the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 22:27:17 +02:00
parent 5c531f11da
commit 574d71371f
50 changed files with 700 additions and 3068 deletions

View File

@@ -3,7 +3,6 @@ import { Timeline } from './components/timeline/Timeline'
import { DuplicatesView } from './components/duplicates/DuplicatesView'
import { MapView } from './components/map/MapView'
import { MemoriesView } from './components/memories/MemoriesView'
import { PeopleView } from './components/people/PeopleView'
import { TagsView } from './components/tags/TagsView'
import { ColorsView } from './components/colors/ColorsView'
import { RatedView } from './components/rated/RatedView'
@@ -92,8 +91,6 @@ function MainApp() {
<MemoriesView />
) : currentSection === 'duplicates' ? (
<DuplicatesView />
) : currentSection === 'people' ? (
<PeopleView />
) : currentSection === 'tags' ? (
<TagsView />
) : currentSection === 'colors' ? (

View File

@@ -16,10 +16,6 @@ import {
FolderSearch,
Shield,
Brain,
ScanText,
UserSquare2,
Boxes,
Tags as TagsIcon,
RotateCcw,
} from 'lucide-react'
import clsx from 'clsx'
@@ -1107,56 +1103,20 @@ interface AiFeaturesTabProps {
) => 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)',
label: 'Vision classifier',
description:
'When off, every AI stage below is skipped — including newly uploaded photos. ' +
'Existing results stay intact.',
'Binary photo-vs-other classifier. Flags screenshots, documents, memes and ' +
'scans with "needs review" so they can be triaged.',
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) {
@@ -1183,12 +1143,11 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
queryClient.invalidateQueries({ queryKey: ['features'] })
}
type BackfillTask = 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null
const runBackfill = (task: BackfillTask) =>
const runBackfill = () =>
runAction(
`ai-backfill:${task ?? 'all'}`,
() => adminApi.triggerAiBackfill({ task }),
task ? `Backfill queued for ${task}` : 'Full backfill queued',
`ai-backfill:all`,
() => adminApi.triggerAiBackfill({}),
'Classifier backfill queued',
(r) => `Celery task ${r.task_id}`,
)
@@ -1286,17 +1245,6 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
</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>
)
})}
@@ -1306,35 +1254,17 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
<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.
Run the classifier over any photos that haven't been classified yet,
or force a fresh filesystem scan. Both are safe to run repeatedly.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<ActionButton
loading={!!busy['ai-backfill:all']}
onClick={() => runBackfill(null)}
onClick={() => runBackfill()}
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
Run classifier backfill
</ActionButton>
<ActionButton
loading={!!busy['rescan-full']}

View File

@@ -46,6 +46,8 @@ export function FilterBar() {
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
const tagIds = useFilterStore((s) => s.tagIds)
const needsReview = useFilterStore((s) => s.needsReview)
const setNeedsReview = useFilterStore((s) => s.setNeedsReview)
const currentSection = useFilterStore((s) => s.currentSection)
// Only the Flag pill is hidden inside the Discarded section. Flag has
@@ -129,251 +131,263 @@ export function FilterBar() {
const anyActive = hasActiveFilters(filterState)
return (
// Fixed bar height + py-0 so neither the active filter pills nor the
// clear-all button can stretch the bar vertically. The fixed h-11
// matches the h-7 pills + 8px symmetric vertical padding.
<div className="flex h-11 items-center gap-3 border-b border-border bg-surface px-3 py-0">
<div className="flex h-9 items-center gap-3 border-b border-border bg-surface px-3 py-0">
{/* Pills — left side, scroll horizontally if they overflow. */}
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{/* Date */}
<FilterPill
label="Date"
value={dateValue}
isActive={dateActive}
onClear={() => {
setDateFrom(null)
setDateTo(null)
}}
>
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] text-text-muted">From</label>
<input
type="date"
value={dateFrom ?? ''}
onChange={(e) => setDateFrom(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
{/* Date */}
<FilterPill
label="Date"
value={dateValue}
isActive={dateActive}
onClear={() => {
setDateFrom(null)
setDateTo(null)
}}
>
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] text-text-muted">From</label>
<input
type="date"
value={dateFrom ?? ''}
onChange={(e) => setDateFrom(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</div>
<div>
<label className="mb-1 block text-[11px] text-text-muted">To</label>
<input
type="date"
value={dateTo ?? ''}
onChange={(e) => setDateTo(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</div>
</div>
<div>
<label className="mb-1 block text-[11px] text-text-muted">To</label>
<input
type="date"
value={dateTo ?? ''}
onChange={(e) => setDateTo(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</div>
</div>
</FilterPill>
</FilterPill>
{/* Type */}
<FilterPill
label="Type"
value={typeValue}
isActive={typeActive}
onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
>
<div className="flex flex-wrap gap-1">
{MEDIA_TYPES.map(({ value, label }) => {
const active = mediaTypes.includes(value)
return (
{/* Type */}
<FilterPill
label="Type"
value={typeValue}
isActive={typeActive}
onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
>
<div className="flex flex-wrap gap-1">
{MEDIA_TYPES.map(({ value, label }) => {
const active = mediaTypes.includes(value)
return (
<button
key={value}
onClick={() => toggleMediaType(value)}
className={clsx(
'rounded px-2 py-1 text-xs transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{label}
</button>
)
})}
</div>
</FilterPill>
{/* Rating */}
<FilterPill
label="Rating"
value={ratingValue}
isActive={ratingActive}
onClear={() => setRatingMin(0)}
>
<div>
<p className="mb-1 text-[11px] text-text-muted">Minimum</p>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => setRatingMin(ratingMin === n ? 0 : n)}
className="p-0.5"
title={`At least ${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={clsx(
'h-5 w-5 transition-colors',
n <= ratingMin
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
</FilterPill>
{/* Color */}
<FilterPill
label="Color"
value={colorValue}
isActive={colorActive}
onClear={() => setColorLabel(null)}
>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
const active = colorLabel === value
return (
<button
key={value}
onClick={() => setColorLabel(active ? null : value)}
className={clsx(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
)}
title={value}
/>
)
})}
{colorLabel && (
<button
key={value}
onClick={() => toggleMediaType(value)}
onClick={() => setColorLabel(null)}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear color"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</FilterPill>
{/* Flag — hidden in the Discarded section, where the flag is
* pinned to "discarded" by the section preset. */}
{!hideFlagPill && (
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => setFlag('any')}
>
<div className="flex flex-col gap-1">
<button
onClick={() => setFlag('any')}
className={clsx(
'rounded px-2 py-1 text-xs transition-colors',
active
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'any'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{label}
Any
</button>
)
})}
</div>
</FilterPill>
{/* Rating */}
<FilterPill
label="Rating"
value={ratingValue}
isActive={ratingActive}
onClear={() => setRatingMin(0)}
>
<div>
<p className="mb-1 text-[11px] text-text-muted">Minimum</p>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => setRatingMin(ratingMin === n ? 0 : n)}
className="p-0.5"
title={`At least ${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={clsx(
'h-5 w-5 transition-colors',
n <= ratingMin
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
</FilterPill>
{/* Color */}
<FilterPill
label="Color"
value={colorValue}
isActive={colorActive}
onClear={() => setColorLabel(null)}
>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
const active = colorLabel === value
return (
<button
key={value}
onClick={() => setColorLabel(active ? null : value)}
onClick={() => setFlag('discarded')}
className={clsx(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'discarded'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
title={value}
/>
)
})}
{colorLabel && (
<button
onClick={() => setColorLabel(null)}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear color"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</FilterPill>
>
Discarded
</button>
<button
onClick={() => setFlag('date_warning')}
className={clsx(
'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'date_warning'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
title="Photos whose folder/filename suggests a different date than the stored taken_at"
>
<AlertTriangle className="h-3 w-3" />
Date issues
</button>
</div>
</FilterPill>
)}
{/* Flag — hidden in the Discarded section, where the flag is
* pinned to "discarded" by the section preset. */}
{!hideFlagPill && (
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => setFlag('any')}
{/* Needs review — binary toggle */}
<button
onClick={() => setNeedsReview(!needsReview)}
className={clsx(
'flex h-6 items-center gap-1 whitespace-nowrap rounded-full border px-2.5 text-xs transition-colors',
needsReview
? 'border-primary bg-primary text-white'
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
title="Show only photos classified as non-photographs (screenshots, documents, memes)"
>
<div className="flex flex-col gap-1">
<button
onClick={() => setFlag('any')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'any'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
<AlertTriangle className="h-3 w-3" />
Needs review
</button>
{/* Tags */}
{allTags.length > 0 && (
<FilterPill
label="Tags"
value={tagValue}
isActive={tagActive}
onClear={() => setTagIds([])}
>
<TagFilterPopover
allTags={allTags}
selectedIds={tagIds}
onToggle={toggleTagId}
onClear={() => setTagIds([])}
/>
</FilterPill>
)}
{/* Sort — always present, never "active/inactive" since there's
always a value. */}
<FilterPill label="Sort" value={sortValue} isActive>
<div className="space-y-2">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortField)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
>
Any
</button>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={() => setFlag('discarded')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'discarded'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
onClick={toggleSortOrder}
className="flex w-full items-center justify-center gap-1 rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset hover:text-text"
>
Discarded
</button>
<button
onClick={() => setFlag('date_warning')}
className={clsx(
'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'date_warning'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
{sortOrder === 'desc' ? (
<>
<ArrowDown className="h-3.5 w-3.5" />
Descending
</>
) : (
<>
<ArrowUp className="h-3.5 w-3.5" />
Ascending
</>
)}
title="Photos whose folder/filename suggests a different date than the stored taken_at"
>
<AlertTriangle className="h-3 w-3" />
Date issues
</button>
</div>
</FilterPill>
)}
{/* Tags */}
{allTags.length > 0 && (
<FilterPill
label="Tags"
value={tagValue}
isActive={tagActive}
onClear={() => setTagIds([])}
>
<TagFilterPopover
allTags={allTags}
selectedIds={tagIds}
onToggle={toggleTagId}
onClear={() => setTagIds([])}
/>
</FilterPill>
)}
{/* Sort — always present, never "active/inactive" since there's
always a value. */}
<FilterPill label="Sort" value={sortValue} isActive>
<div className="space-y-2">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortField)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={toggleSortOrder}
className="flex w-full items-center justify-center gap-1 rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset hover:text-text"
>
{sortOrder === 'desc' ? (
<>
<ArrowDown className="h-3.5 w-3.5" />
Descending
</>
) : (
<>
<ArrowUp className="h-3.5 w-3.5" />
Ascending
</>
)}
</button>
</div>
</FilterPill>
{/* Clear-all — borderless text affordance pinned next to the pill
{/* Clear-all — borderless text affordance pinned next to the pill
* cluster on the right. Lives inside the pills container so it
* shares the same flex group and gap and reads as "another
* pill". Only renders when any filter is active. */}
{anyActive && (
<button
onClick={clearAll}
className="ml-1 flex h-7 flex-shrink-0 items-center whitespace-nowrap px-1 text-xs text-text-muted underline-offset-2 hover:text-text hover:underline"
title="Clear all filters in this section"
>
Clear all
</button>
)}
{anyActive && (
<button
onClick={clearAll}
className="ml-1 flex h-7 flex-shrink-0 items-center whitespace-nowrap px-1 text-xs text-text-muted underline-offset-2 hover:text-text hover:underline"
title="Clear all filters in this section"
>
Clear all
</button>
)}
</div>
{/* Search — pinned to the right edge of the bar. Same id as before

View File

@@ -80,17 +80,10 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
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 tagsOn = true
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..."
@@ -295,8 +288,8 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
case 'tags':
navigateToSection('tags', { groupBy: 'tag' })
break
case 'people':
navigateToSection('people', { groupBy: 'tag' })
case 'needs-review':
navigateToSection('needs-review', { needsReview: true })
break
case 'colors':
navigateToSection('colors', { groupBy: 'color' })
@@ -425,7 +418,6 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
// Total tag count for the badge on the Tags entry (user tags only).
const userTags = allTags.filter((t) => t.kind === 'user')
const tagsTotalCount = userTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const peopleTotalCount = faceClusters.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const libraryTree: TreeItem[] = [
{
@@ -436,7 +428,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
{ 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 },
...(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 }] : []),
...(visionOn ? [{ id: 'needs-review', label: 'Needs Review', icon: <Users className="h-4 w-4" />, count: stats?.needs_review ?? 0 }] : []),
{ 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" /> },
@@ -516,7 +508,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
style={
isSectionHeader
? undefined
: { paddingLeft: `${8 + (depth - 1) * 12}px` }
: { paddingLeft: `${depth * 20}px` }
}
onClick={() => {
if (renamingId === item.id) return
@@ -580,7 +572,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
)}
</button>
) : (
!isSectionHeader && <div className="w-3" />
!isSectionHeader && <div className="h-4 w-4 flex-shrink-0" />
)}
{/* Item Icon — section headers drop their icon in favor of the
@@ -783,7 +775,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
creatingUnder === item.id.slice('folder-'.length) && (
<div
className="flex items-center gap-1 px-2 py-1"
style={{ paddingLeft: `${8 + (depth + 1) * 16 + 4}px` }}
style={{ paddingLeft: `${(depth + 1) * 20}px` }}
>
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
<input

View File

@@ -1,241 +0,0 @@
import { useState, useCallback, useMemo } from 'react'
import { Users, Pencil, Check, X, Loader2, ArrowLeft } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import {
tags as tagsApi,
photos as photosApi,
type Tag,
} from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
import { toast } from '../ToastContainer'
/**
* People view — two states:
* 1. Grid of face cluster cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a person's photos in the full Timeline — Esc to go back
*/
export function PeopleView() {
const { data: rawClusters = [], isLoading } = useTagsQuery('face_cluster')
const clusters = useMemo(
() => [...rawClusters].sort((a, b) => b.photo_count - a.photo_count),
[rawClusters]
)
const queryClient = useQueryClient()
const setTagIds = useFilterStore((s) => s.setTagIds)
const [selectedPerson, setSelectedPerson] = useState<Tag | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
tagsApi.update(id, { name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] })
setEditingId(null)
if (selectedPerson && editingId === selectedPerson.id) {
setSelectedPerson({ ...selectedPerson, name: editName.trim() })
}
toast.success('Renamed')
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message),
})
const startEditing = (tag: Tag) => {
setEditingId(tag.id)
setEditName(tag.name)
}
const submitRename = () => {
if (!editingId || !editName.trim()) return
renameMutation.mutate({ id: editingId, name: editName.trim() })
}
const enterDetail = useCallback(
(person: Tag) => {
setTagIds([person.id])
setSelectedPerson(person)
},
[setTagIds]
)
const exitDetail = useCallback(() => {
setTagIds([])
setSelectedPerson(null)
}, [setTagIds])
const { activeIndex, gridRef } = useCardGridNav({
items: clusters,
inDetail: selectedPerson !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
// ── Detail view: a person's photos ─────────────────────────────────
if (selectedPerson) {
const isEditing = editingId === selectedPerson.id
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to people"
>
<ArrowLeft className="h-4 w-4" />
</button>
{isEditing ? (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') setEditingId(null)
}}
className="rounded border border-border bg-bg px-2 py-1 text-sm text-text focus:border-primary focus:outline-none"
/>
<button onClick={submitRename} className="rounded p-1 text-green-500 hover:bg-green-500/10">
<Check className="h-4 w-4" />
</button>
<button onClick={() => setEditingId(null)} className="rounded p-1 text-text-muted hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-text">{selectedPerson.name}</h2>
<button
onClick={() => startEditing(selectedPerson)}
className="rounded p-0.5 text-text-muted transition-colors hover:text-text"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
// ── Card grid ──────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading people...
</div>
)
}
if (clusters.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Users className="h-12 w-12 opacity-40" />
<p className="text-sm">No people identified yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Face detection runs automatically when photos are scanned.
People will appear here once faces are found and clustered.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4 pb-20">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<Users className="h-4 w-4" />
<span className="text-sm font-medium">
{clusters.length} {clusters.length === 1 ? 'person' : 'people'} identified
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{clusters.map((tag, i) => (
<div
key={tag.id}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: editingId === tag.id
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => {
if (editingId !== tag.id) enterDetail(tag)
}}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{tag.representative_photo_id ? (
<img
src={photosApi.getThumbnailUrl(tag.representative_photo_id, 'small')}
alt={tag.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Users className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{tag.photo_count}
</span>
<button
className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
startEditing(tag)
}}
title="Rename"
>
<Pencil className="h-3 w-3" />
</button>
</div>
<div className="px-2 py-1.5">
{editingId === tag.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') setEditingId(null)
}}
className="min-w-0 flex-1 rounded border border-border bg-bg px-1.5 py-0.5 text-xs text-text focus:border-primary focus:outline-none"
/>
<button onClick={submitRename} className="rounded p-0.5 text-green-500 hover:bg-green-500/10">
<Check className="h-3 w-3" />
</button>
<button onClick={() => setEditingId(null)} className="rounded p-0.5 text-text-muted hover:bg-surface-2">
<X className="h-3 w-3" />
</button>
</div>
) : (
<p className="truncate text-xs font-medium text-text">{tag.name}</p>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -17,11 +17,10 @@ export function TagsView() {
const setTagIds = useFilterStore((s) => s.setTagIds)
const [selectedTag, setSelectedTag] = useState<Tag | null>(null)
// Exclude face_cluster tags (those live in PeopleView)
const tags = useMemo(
() =>
allTags
.filter((t) => t.kind !== 'face_cluster')
.filter((t) => t.kind === 'user')
.sort((a, b) => b.photo_count - a.photo_count),
[allTags]
)

View File

@@ -18,14 +18,7 @@ export function useFeaturesQuery() {
})
}
export function useIsFeatureEnabled(
name:
| 'vision.enabled'
| 'vision.ocr.enabled'
| 'vision.detector.enabled'
| 'vision.faces.enabled'
| 'vision.classifier.enabled',
): boolean {
export function useIsFeatureEnabled(name: 'vision.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;

View File

@@ -89,6 +89,7 @@ function parseUrl(): HydratePayload {
}
if (sp.get('duplicates') === 'true') out.duplicates = true
if (sp.get('needs_review') === 'true') out.needsReview = true
const groupBy = sp.get('group')
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
@@ -123,6 +124,7 @@ function writeUrl(f: FilterState & { currentSection?: string }) {
if (f.folderId) sp.set('folder_id', f.folderId)
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
if (f.duplicates) sp.set('duplicates', 'true')
if (f.needsReview) sp.set('needs_review', 'true')
if (f.groupBy !== 'date') sp.set('group', f.groupBy)
if (f.currentSection && f.currentSection !== 'all-photos')
sp.set('section', f.currentSection)

View File

@@ -52,6 +52,7 @@ export function usePhotosQuery() {
const folderId = useFilterStore((s) => s.folderId)
const tagIds = useFilterStore((s) => s.tagIds)
const duplicates = useFilterStore((s) => s.duplicates)
const needsReview = useFilterStore((s) => s.needsReview)
const groupBy = useFilterStore((s) => s.groupBy)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
@@ -71,11 +72,12 @@ export function usePhotosQuery() {
folderId,
tagIds,
duplicates,
needsReview,
groupBy,
sortBy,
sortOrder,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, needsReview, groupBy, sortBy, sortOrder]
)
const queryClient = useQueryClient()

View File

@@ -438,7 +438,6 @@ export interface PipelineStage {
export interface PipelineStats {
total_photos: number
total_images: number
embedder_model: string
stages: PipelineStage[]
}
@@ -621,6 +620,7 @@ export interface LibraryStats {
with_gps: number
duplicates: number
discarded: number
needs_review: number
total_photos: number
total_videos: number
total_size: number
@@ -843,7 +843,7 @@ export const sharing = {
}
// Tags API
export type TagKind = 'user' | 'object' | 'scene' | 'face_cluster'
export type TagKind = 'user' | 'content_type'
export interface Tag {
id: string
@@ -876,11 +876,6 @@ export const tags = {
await api.delete(`/tags/${tagId}`)
},
merge: async (sourceId: string, targetId: string): Promise<{ merged_into: string; target_name: string }> => {
const response = await api.post(`/tags/${sourceId}/merge`, { target_id: targetId })
return response.data
},
/** Add one or more tags to a photo. */
addToPhoto: async (photoId: string, tagIds: string[]) => {
const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds })
@@ -1013,18 +1008,12 @@ export const admin = {
},
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

View File

@@ -31,6 +31,8 @@ export interface FilterState {
tagIds: string[]
/** When true, restrict to photos flagged as duplicates by the scanner. */
duplicates: boolean
/** When true, restrict to photos classified as 'other' (needs_review). */
needsReview: boolean
/** Visual grouping mode. 'date' groups by month when sortBy is a date
* field; 'tag' groups by photo tag membership. Independent of filters. */
groupBy: GroupBy
@@ -68,6 +70,7 @@ interface FilterStore extends FilterState {
setTagIds: (ids: string[]) => void
toggleTagId: (id: string) => void
setDuplicates: (v: boolean) => void
setNeedsReview: (v: boolean) => void
setGroupBy: (mode: GroupBy) => void
setSortBy: (field: SortField) => void
setSortOrder: (order: SortOrder) => void
@@ -102,6 +105,7 @@ export const INITIAL_FILTERS: FilterState = {
folderId: null,
tagIds: [],
duplicates: false,
needsReview: false,
groupBy: 'date',
sortBy: 'taken_at',
sortOrder: 'desc',
@@ -124,6 +128,7 @@ function snapshotFilters(s: FilterState): FilterState {
folderId: s.folderId,
tagIds: [...s.tagIds],
duplicates: s.duplicates,
needsReview: s.needsReview,
groupBy: s.groupBy,
sortBy: s.sortBy,
sortOrder: s.sortOrder,
@@ -159,6 +164,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
: [...s.tagIds, id],
})),
setDuplicates: (duplicates) => set({ duplicates }),
setNeedsReview: (needsReview) => set({ needsReview }),
setGroupBy: (groupBy) => set({ groupBy }),
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
@@ -218,6 +224,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.folderId) params.folder_id = f.folderId
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
if (f.duplicates) params.is_duplicate = 'true'
if (f.needsReview) params.needs_review = 'true'
params.sort = f.sortBy
params.order = f.sortOrder
return params
@@ -237,6 +244,7 @@ export function hasActiveFilters(f: FilterState): boolean {
f.heapId !== null ||
f.folderId !== null ||
f.tagIds.length > 0 ||
f.duplicates
f.duplicates ||
f.needsReview
)
}

View File

@@ -17,6 +17,7 @@ export interface Photo {
color_label?: string | null
is_discarded: boolean
is_duplicate: boolean
needs_review?: boolean
has_date_warning?: boolean
file_hash: string
folder_id: string | null