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:
@@ -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']}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user