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:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Star, X, ArrowDown, ArrowUp, Search } from 'lucide-react'
|
||||
import { Star, X, ArrowDown, ArrowUp, Search, AlertTriangle } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
useFilterStore,
|
||||
@@ -42,6 +42,8 @@ export function FilterBar() {
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const dateWarning = useFilterStore((s) => s.dateWarning)
|
||||
const setDateWarning = useFilterStore((s) => s.setDateWarning)
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||
const tagIds = useFilterStore((s) => s.tagIds)
|
||||
@@ -322,6 +324,38 @@ export function FilterBar() {
|
||||
</FilterPill>
|
||||
)}
|
||||
|
||||
{/* Date issues — toggle-only pill. Restricts the grid to photos
|
||||
* whose path-based date guess disagrees with the stored taken_at,
|
||||
* so operators can find and fix a whole library's worth of
|
||||
* corrupted or missing capture dates in one pass. Backed by the
|
||||
* `photos.has_date_warning` column set at scan time. */}
|
||||
<button
|
||||
onClick={() => setDateWarning(!dateWarning)}
|
||||
className={clsx(
|
||||
'flex h-7 flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-full border px-2.5 text-xs transition-colors',
|
||||
dateWarning
|
||||
? 'border-amber-500/60 bg-amber-500/15 text-amber-300 hover:bg-amber-500/25'
|
||||
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
title={
|
||||
dateWarning
|
||||
? 'Showing only photos with suspicious capture dates'
|
||||
: 'Show only photos whose folder/filename suggests a different date'
|
||||
}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Date issues
|
||||
{dateWarning && (
|
||||
<X
|
||||
className="ml-0.5 h-3 w-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDateWarning(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Sort — always present, never "active/inactive" since there's
|
||||
always a value. */}
|
||||
<FilterPill label="Sort" value={sortValue} isActive>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
@@ -32,7 +32,7 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
|
||||
ref={isActive ? activeRef : null}
|
||||
onClick={() => onSelect(photo.id)}
|
||||
className={clsx(
|
||||
'shrink-0 overflow-hidden rounded-sm transition-all',
|
||||
'relative shrink-0 overflow-hidden rounded-sm transition-all',
|
||||
'hover:opacity-100',
|
||||
isActive
|
||||
? 'ring-2 ring-primary opacity-100'
|
||||
@@ -41,15 +41,45 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
|
||||
style={{ width: CELL_SIZE, height: CELL_SIZE }}
|
||||
title={photo.filename}
|
||||
>
|
||||
<img
|
||||
src={photosApi.getThumbnailUrl(photo.id, 'small')}
|
||||
alt={photo.filename}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<FilmstripThumb photo={photo} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilmstripThumb({ photo }: { photo: Photo }) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [errored, setErrored] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false)
|
||||
setErrored(false)
|
||||
}, [photo.id])
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loaded && !errored && (
|
||||
<div className="absolute inset-0 animate-pulse bg-bg" />
|
||||
)}
|
||||
{errored && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-bg">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-text-muted/50" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={photosApi.getThumbnailUrl(photo.id, 'small')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setErrored(true)}
|
||||
className={clsx(
|
||||
'h-full w-full object-cover transition-opacity duration-200',
|
||||
loaded ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
keepPreviousData,
|
||||
} from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
photos as photosApi,
|
||||
@@ -28,6 +33,10 @@ import {
|
||||
COLOR_LABEL_OPTIONS,
|
||||
type ColorLabel,
|
||||
} from '../../constants/colorLabels'
|
||||
import {
|
||||
guessDateFromPath,
|
||||
toDatetimeLocalValue,
|
||||
} from '../../lib/guessDateFromPath'
|
||||
|
||||
interface PhotoTagSummary {
|
||||
id: string
|
||||
@@ -43,6 +52,7 @@ interface PhotoDetails {
|
||||
height: number | null
|
||||
file_size: number | null
|
||||
taken_at: string | null
|
||||
taken_at_source: string | null
|
||||
rating: number
|
||||
is_discarded: boolean
|
||||
user_title: string | null
|
||||
@@ -137,12 +147,16 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
setExpandedSections(next)
|
||||
}
|
||||
|
||||
// Fetch the photo's full record (with EXIF) on demand.
|
||||
const { data: photo } = useQuery<PhotoDetails>({
|
||||
// Fetch the photo's full record (with EXIF) on demand. `keepPreviousData`
|
||||
// holds the last photo on screen while the next one loads, so arrow-nav
|
||||
// through the preview doesn't flash the "Loading…" placeholder between
|
||||
// every neighbour — the panel swaps in place once the new record arrives.
|
||||
const { data: photo, isPlaceholderData } = useQuery<PhotoDetails>({
|
||||
queryKey: ['photo', photoId],
|
||||
queryFn: () => photosApi.get(photoId),
|
||||
enabled: !!photoId,
|
||||
staleTime: 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
// Mutation for any patchable field. Invalidates both the photo detail
|
||||
@@ -155,6 +169,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
user_title?: string | null
|
||||
user_notes?: string | null
|
||||
color_label?: string | null
|
||||
taken_at?: string
|
||||
}) => photosApi.update(photoId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||
@@ -240,12 +255,22 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
const [filenameDraft, setFilenameDraft] = useState('')
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
const [notesDraft, setNotesDraft] = useState('')
|
||||
const [takenAtDraft, setTakenAtDraft] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setFilenameDraft(photo?.filename ?? '')
|
||||
setTitleDraft(photo?.user_title ?? '')
|
||||
setNotesDraft(photo?.user_notes ?? '')
|
||||
}, [photo?.id, photo?.filename, photo?.user_title, photo?.user_notes])
|
||||
setTakenAtDraft(
|
||||
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
|
||||
)
|
||||
}, [
|
||||
photo?.id,
|
||||
photo?.filename,
|
||||
photo?.user_title,
|
||||
photo?.user_notes,
|
||||
photo?.taken_at,
|
||||
])
|
||||
|
||||
const commitFilename = () => {
|
||||
const next = filenameDraft.trim()
|
||||
@@ -287,6 +312,44 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
updateMutation.mutate({ user_notes: next || null })
|
||||
}
|
||||
|
||||
/** Commit a datetime-local draft back to the server. The backend also
|
||||
* rewrites EXIF on disk, so a failure here rolls the draft back to the
|
||||
* server value — we never want the UI to silently disagree with the
|
||||
* file. An empty string is a no-op because the input's `required` is
|
||||
* off and we don't yet have a "clear date" affordance. */
|
||||
const commitTakenAt = (rawValue?: string) => {
|
||||
const source = rawValue ?? takenAtDraft
|
||||
if (!source) return
|
||||
const parsed = new Date(source)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
toast.error('Invalid date', 'Could not parse the value')
|
||||
setTakenAtDraft(
|
||||
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
|
||||
)
|
||||
return
|
||||
}
|
||||
const iso = parsed.toISOString()
|
||||
if (photo?.taken_at && new Date(photo.taken_at).toISOString() === iso) {
|
||||
return
|
||||
}
|
||||
updateMutation.mutate(
|
||||
{ taken_at: iso },
|
||||
{
|
||||
onError: (e: any) => {
|
||||
toast.error(
|
||||
'Date update failed',
|
||||
e?.response?.data?.detail || e.message || 'Unknown error'
|
||||
)
|
||||
setTakenAtDraft(
|
||||
photo?.taken_at
|
||||
? toDatetimeLocalValue(new Date(photo.taken_at))
|
||||
: ''
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||
|
||||
if (!photo) {
|
||||
@@ -313,7 +376,12 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-full flex-col transition-opacity duration-150',
|
||||
isPlaceholderData && 'opacity-70'
|
||||
)}
|
||||
>
|
||||
{/* Edit fields */}
|
||||
<div className="space-y-2.5 border-b border-border p-3">
|
||||
<div>
|
||||
@@ -505,15 +573,14 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Date Taken"
|
||||
value={
|
||||
photo.taken_at
|
||||
? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<TakenAtEditor
|
||||
photo={photo}
|
||||
draft={takenAtDraft}
|
||||
onDraftChange={setTakenAtDraft}
|
||||
onCommit={commitTakenAt}
|
||||
darkTheme={darkTheme}
|
||||
/>
|
||||
{/* Filepath spans the full sidebar width — most paths are long
|
||||
* enough that the two-column grid above wraps them painfully.
|
||||
* Mono so each character lines up under the next, break-all
|
||||
@@ -761,3 +828,114 @@ function Field({ label, value }: { label: string; value: string }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TakenAtEditorProps {
|
||||
photo: PhotoDetails
|
||||
draft: string
|
||||
onDraftChange: (v: string) => void
|
||||
onCommit: (raw?: string) => void
|
||||
darkTheme: boolean
|
||||
}
|
||||
|
||||
/** Editable Date Taken field with a source badge (EXIF / filesystem / manual)
|
||||
* and a folder-guess suggestion row that only shows up when the filepath
|
||||
* implies a different date than what's currently stored. The suggestion
|
||||
* hint is the whole point of this feature — epoch-reset phones and
|
||||
* corrupted EXIF dumps end up clustered in the wrong corner of the
|
||||
* timeline until someone rewrites them from the folder name. */
|
||||
function TakenAtEditor({
|
||||
photo,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onCommit,
|
||||
darkTheme,
|
||||
}: TakenAtEditorProps) {
|
||||
const source = photo.taken_at_source ?? null
|
||||
const sourceLabel =
|
||||
source === 'exif'
|
||||
? 'EXIF'
|
||||
: source === 'filesystem'
|
||||
? 'FILE'
|
||||
: source === 'manual'
|
||||
? 'MANUAL'
|
||||
: null
|
||||
|
||||
const guess = useMemo(
|
||||
() => guessDateFromPath(photo.filepath),
|
||||
[photo.filepath]
|
||||
)
|
||||
|
||||
// Show the suggestion when:
|
||||
// - there's no stored date at all, OR
|
||||
// - the guess disagrees with the stored date by more than a day.
|
||||
// A same-day match is treated as "already correct enough" so we don't
|
||||
// nag the user on photos that happen to sit in a dated folder.
|
||||
const showSuggestion = useMemo(() => {
|
||||
if (!guess) return false
|
||||
if (!photo.taken_at) return true
|
||||
const current = new Date(photo.taken_at).getTime()
|
||||
const suggested = guess.date.getTime()
|
||||
return Math.abs(current - suggested) > 24 * 60 * 60 * 1000
|
||||
}, [guess, photo.taken_at])
|
||||
|
||||
const inputClass = clsx(
|
||||
'flex-1 rounded border px-2 py-1 text-xs focus:outline-none',
|
||||
darkTheme
|
||||
? 'border-white/15 bg-black/40 text-white focus:border-primary'
|
||||
: 'border-border bg-bg text-text focus:border-primary'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-2 text-xs">
|
||||
<label className="mb-1 block text-text-muted">Date Taken</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onBlur={() => onCommit()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
onDraftChange(
|
||||
photo.taken_at
|
||||
? toDatetimeLocalValue(new Date(photo.taken_at))
|
||||
: ''
|
||||
)
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
{sourceLabel && (
|
||||
<span
|
||||
className="rounded-sm bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-white"
|
||||
title={`Source: ${sourceLabel.toLowerCase()}`}
|
||||
>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showSuggestion && guess && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = toDatetimeLocalValue(guess.date)
|
||||
onDraftChange(next)
|
||||
onCommit(next)
|
||||
}}
|
||||
className={clsx(
|
||||
'mt-1.5 flex w-full items-center justify-between gap-2 rounded border border-dashed px-2 py-1 text-[11px] transition-colors',
|
||||
'border-primary/50 text-primary hover:bg-primary/10'
|
||||
)}
|
||||
title={`Match "${guess.matched}" in path (${guess.source}, ${guess.confidence} confidence)`}
|
||||
>
|
||||
<span className="truncate">
|
||||
Folder suggests {format(guess.date, 'MMM d, yyyy')}
|
||||
</span>
|
||||
<span className="shrink-0 font-semibold">Apply</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Star, ShoppingBasket, Trash2, RefreshCw, Check, Copy } from 'lucide-react'
|
||||
import {
|
||||
Star,
|
||||
ShoppingBasket,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Check,
|
||||
Copy,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
@@ -92,6 +100,13 @@ export function PhotoThumbnail({
|
||||
const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium')
|
||||
const thumbnailUrl = retryCount > 0 ? `${baseUrl}?retry=${retryCount}` : baseUrl
|
||||
|
||||
// "Capture date probably wrong" — read straight from the stored
|
||||
// `has_date_warning` flag rather than recomputing the heuristic
|
||||
// client-side. The backend sets this column at scan time and
|
||||
// refreshes it on any taken_at edit, so the UI, the filter, and the
|
||||
// thumbnail badge all read from one source of truth.
|
||||
const dateWarning = photo.has_date_warning === true
|
||||
|
||||
// Square cells (Lightroom-style grid). Variable-aspect cells previously
|
||||
// overflowed their row because TanStack Virtual estimates row height as a
|
||||
// single fixed value — portraits in a landscape row would overlap the row
|
||||
@@ -323,6 +338,21 @@ export function PhotoThumbnail({
|
||||
<Trash2 className={THUMB_BADGE_ICON} strokeWidth={2.5} />
|
||||
</div>
|
||||
)}
|
||||
{dateWarning && (
|
||||
<div
|
||||
className={clsx(
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_SQUARE,
|
||||
// Amber fill with the same chiseled frame as other affirmative
|
||||
// badges so it reads as a first-class warning rather than a
|
||||
// neutral info chip.
|
||||
'bg-amber-500 shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]'
|
||||
)}
|
||||
title="Capture date may be wrong — folder/filename suggests a different date"
|
||||
>
|
||||
<AlertTriangle className={THUMB_BADGE_ICON} strokeWidth={2.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TR — file-type metadata (RAW / VIDEO) */}
|
||||
|
||||
Reference in New Issue
Block a user