feat: editable taken_at + folder-based date repair and filter

Lets operators fix corrupted capture dates at scale. Adds an editable
Date Taken field with a folder/filename-derived suggestion hint, a bulk
Date Taken section in the multi-select sidebar that either applies one
date to the whole selection or infers a per-photo date from each path,
a warning badge on thumbnails whose stored date disagrees with the
path, and a "Date issues" filter pill so suspicious photos can be
surfaced and fixed as a group. Edits are written back to EXIF on disk
so rescans don't clobber the fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 11:48:55 +02:00
parent 339e1be510
commit 30d03d8d4d
19 changed files with 1281 additions and 26 deletions

View File

@@ -2,12 +2,18 @@ import { useState } from 'react'
import { X, Star, ShoppingBasket, Trash2, Plus, PanelRightClose } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore'
import {
photos as photosApi,
heaps as heapsApi,
tags as tagsApi,
} from '../../services/api'
import type { Photo } from '../../types/photo'
import {
guessDateFromPath,
type DateGuess,
} from '../../lib/guessDateFromPath'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
@@ -59,6 +65,47 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
onSuccess: invalidatePhotoQueries,
})
// Shared report-and-invalidate tail for both bulk taken_at mutations.
// They return a partial-apply shape (updated/skipped/errors) because
// EXIF writes can fail per-photo (unsupported format, missing file)
// without wrecking the rest of the batch.
const reportBulkTakenAt = (
data: {
status: string
updated: number
skipped: number
errors: { id: string; message: string }[]
},
) => {
const errCount = data.errors?.length ?? 0
const detail =
errCount > 0
? `${data.updated} updated · ${errCount} error${errCount === 1 ? '' : 's'}`
: `${data.updated} updated`
if (errCount > 0) {
toast.error('Date update partial', detail)
} else {
toast.success('Dates updated', detail)
}
invalidatePhotoQueries()
}
const bulkTakenAtMutation = useMutation({
mutationFn: ({ ids, iso }: { ids: string[]; iso: string }) =>
photosApi.bulkSetTakenAt(ids, iso),
onSuccess: reportBulkTakenAt,
onError: (e: any) =>
toast.error('Date update failed', e?.message || 'Unknown error'),
})
const bulkTakenAtMapMutation = useMutation({
mutationFn: (map: Record<string, string>) =>
photosApi.bulkSetTakenAtMap(map),
onSuccess: reportBulkTakenAt,
onError: (e: any) =>
toast.error('Date update failed', e?.message || 'Unknown error'),
})
// Bulk tag mutations. Tag mutations also need to invalidate the tags
// query so the FilterBar / sidebar tag counts stay fresh.
const invalidateTagsAndPhotos = () => {
@@ -228,6 +275,37 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
// ── Multi-photo: bulk action panel ──────────────────────────────────
const allMembers = selectedPhotos.every((id) => activeHeapMembers.has(id))
/** Walk the react-query cache for every selected id and return the
* full Photo records. Checks the standalone `['photo', id]` entry
* first (populated whenever a single-photo view or preview opens),
* then falls back to scanning every cached timeline list for the
* id. Any id with no cached record is skipped — the selection UI
* can't act on a photo the user hasn't loaded yet anyway. */
const collectSelectedPhotos = (): Photo[] => {
const out: Photo[] = []
const seen = new Set<string>()
for (const id of selectedPhotos) {
if (seen.has(id)) continue
const direct = queryClient.getQueryData<Photo>(['photo', id])
if (direct) {
out.push(direct)
seen.add(id)
continue
}
const lists = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
for (const [, list] of lists) {
if (!list) continue
const hit = list.find((p) => p.id === id)
if (hit) {
out.push(hit)
seen.add(id)
break
}
}
}
return out
}
return (
<div className="flex h-full flex-col bg-surface">
<Header />
@@ -361,11 +439,170 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
}}
/>
</div>
{/* Bulk Date Taken — lets an operator repair the capture date on
* a whole selection at once, either by applying one date to
* everything or by inferring a per-photo date from each file's
* folder path and filename. Useful for cameras that lost their
* clock (1970 epoch) and for legacy libraries where the folder
* structure is the only trustworthy date signal. */}
<div>
<label className="mb-1 block text-xs text-text-muted">Date Taken</label>
<BulkTakenAtEditor
disabled={
bulkTakenAtMutation.isPending || bulkTakenAtMapMutation.isPending
}
selectedCount={selectedPhotos.length}
collectPhotos={collectSelectedPhotos}
onApplyUniform={(iso) =>
bulkTakenAtMutation.mutate({ ids: selectedPhotos, iso })
}
onApplyMap={(map) => bulkTakenAtMapMutation.mutate(map)}
/>
</div>
</div>
</div>
)
}
interface BulkTakenAtEditorProps {
disabled: boolean
selectedCount: number
collectPhotos: () => Photo[]
onApplyUniform: (iso: string) => void
onApplyMap: (map: Record<string, string>) => void
}
/** Bulk Date Taken sub-panel used by RightSidebar in multi-select mode.
* Two modes share one UI:
* 1. Apply-one: user types a datetime, clicks Apply, every selected
* photo is rewritten to that date.
* 2. Guess-from-path: we run `guessDateFromPath` against each selected
* photo's filepath, show a preview of the hits + misses, and let
* the user commit the per-photo map in one round-trip. */
function BulkTakenAtEditor({
disabled,
selectedCount,
collectPhotos,
onApplyUniform,
onApplyMap,
}: BulkTakenAtEditorProps) {
const [uniformDraft, setUniformDraft] = useState('')
const [preview, setPreview] = useState<
| {
hits: { photo: Photo; guess: DateGuess }[]
misses: Photo[]
}
| null
>(null)
const handleGuess = () => {
const photos = collectPhotos()
const hits: { photo: Photo; guess: DateGuess }[] = []
const misses: Photo[] = []
for (const p of photos) {
const g = guessDateFromPath(p.filepath)
if (g) hits.push({ photo: p, guess: g })
else misses.push(p)
}
setPreview({ hits, misses })
}
const handleApplyPreview = () => {
if (!preview) return
const map: Record<string, string> = {}
for (const { photo, guess } of preview.hits) {
map[photo.id] = guess.date.toISOString()
}
if (Object.keys(map).length === 0) return
onApplyMap(map)
setPreview(null)
}
const handleApplyUniform = () => {
if (!uniformDraft) return
const parsed = new Date(uniformDraft)
if (Number.isNaN(parsed.getTime())) return
onApplyUniform(parsed.toISOString())
}
return (
<div className="space-y-2">
{/* Apply-one row */}
<div className="flex items-center gap-1.5">
<input
type="datetime-local"
value={uniformDraft}
onChange={(e) => setUniformDraft(e.target.value)}
disabled={disabled}
className="flex-1 rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none disabled:opacity-50"
/>
<button
onClick={handleApplyUniform}
disabled={disabled || !uniformDraft}
className="rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
title={`Apply this date to all ${selectedCount} selected`}
>
Apply
</button>
</div>
{/* Guess-from-path preview */}
{preview === null ? (
<button
onClick={handleGuess}
disabled={disabled}
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
title="Scan each photo's folder + filename for a date pattern"
>
Guess from folder paths
</button>
) : (
<div className="rounded border border-border bg-bg p-2 text-[11px]">
<div className="mb-1.5 text-text-muted">
{preview.hits.length} will update ·{' '}
{preview.misses.length} skipped
</div>
{preview.hits.length > 0 && (
<ul className="mb-1.5 max-h-24 space-y-0.5 overflow-y-auto font-mono text-[10px] text-text">
{preview.hits.slice(0, 5).map(({ photo, guess }) => (
<li key={photo.id} className="truncate" title={photo.filepath}>
<span className="text-text-muted">{photo.filename}</span>
{' → '}
<span className="text-primary">
{format(guess.date, 'yyyy-MM-dd')}
</span>
</li>
))}
{preview.hits.length > 5 && (
<li className="text-text-muted">
and {preview.hits.length - 5} more
</li>
)}
</ul>
)}
<div className="flex gap-1.5">
<button
onClick={handleApplyPreview}
disabled={disabled || preview.hits.length === 0}
className="flex-1 rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
>
Apply {preview.hits.length}
</button>
<button
onClick={() => setPreview(null)}
disabled={disabled}
className="rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
)}
</div>
)
}
interface BulkTagsEditorProps {
allTags: { id: string; name: string; color: string | null }[]
tagInput: string