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