feat(sidebar): path next to date + ISO date-only inputs

Moved the filepath display from the readonly facts row to immediately
above the Date Taken editor, and switched both the single-photo and
bulk Date Taken inputs from datetime-local to date (YYYY-MM-DD). The
date-only compare in commitTakenAt avoids clobbering the stored
time-of-day when the user blurs the field without editing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 10:53:06 +02:00
parent f9f276a986
commit bd39d310ab
4 changed files with 46 additions and 39 deletions

View File

@@ -64,7 +64,11 @@ export function BulkTakenAtEditor({
const handleApplyUniform = () => {
if (!uniformDraft) return
const parsed = new Date(uniformDraft)
// uniformDraft is "YYYY-MM-DD" from a date input; anchor to local
// midnight so the stored ISO matches the day the user picked when
// viewed in their own timezone.
const [year, month, day] = uniformDraft.split('-').map(Number)
const parsed = new Date(year, month - 1, day)
if (Number.isNaN(parsed.getTime())) return
onApplyUniform(parsed.toISOString())
}
@@ -74,7 +78,7 @@ export function BulkTakenAtEditor({
{/* Apply-one row */}
<div className="flex items-center gap-1.5">
<Input
type="datetime-local"
type="date"
value={uniformDraft}
onChange={(e) => setUniformDraft(e.target.value)}
disabled={disabled}

View File

@@ -39,7 +39,7 @@ import {
COLOR_LABEL_OPTIONS,
type ColorLabel,
} from '../../constants/colorLabels'
import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
import { toDateInputValue } from '../../lib/guessDateFromPath'
import { TagsEditor } from './TagsEditor'
import { TakenAtEditor } from './TakenAtEditor'
@@ -311,7 +311,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
setFilenameDraft(photo?.filename ?? '')
setNotesDraft(photo?.user_notes ?? '')
setTakenAtDraft(
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
photo?.taken_at ? toDateInputValue(new Date(photo.taken_at)) : ''
)
}, [
photo?.id,
@@ -353,26 +353,32 @@ 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
/** Commit a date-only 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. */
* off and we don't yet have a "clear date" affordance.
*
* Comparison is YYYY-MM-DD vs YYYY-MM-DD (not full ISO) so a blur with
* no edits doesn't clobber the stored time-of-day with a new local
* midnight. */
const commitTakenAt = (rawValue?: string) => {
const source = rawValue ?? takenAtDraft
if (!source) return
const parsed = new Date(source)
if (Number.isNaN(parsed.getTime())) {
const [y, m, d] = source.split('-').map((n) => Number(n))
if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) {
toast.error('Invalid date', 'Could not parse the value')
setTakenAtDraft(
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
photo?.taken_at ? toDateInputValue(new Date(photo.taken_at)) : ''
)
return
}
const current = photo?.taken_at
? toDateInputValue(new Date(photo.taken_at))
: ''
if (current === source) return
const parsed = new Date(y, m - 1, d)
const iso = parsed.toISOString()
if (photo?.taken_at && new Date(photo.taken_at).toISOString() === iso) {
return
}
updateMutation.mutate(
{ taken_at: iso },
{
@@ -383,7 +389,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
)
setTakenAtDraft(
photo?.taken_at
? toDatetimeLocalValue(new Date(photo.taken_at))
? toDateInputValue(new Date(photo.taken_at))
: ''
)
},
@@ -455,22 +461,6 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
}
/>
</div>
{/* Filepath: full-width mono so it wraps cleanly instead of
* stretching the two-column grid. */}
<div className="text-xs">
<span className="text-text-muted">Path</span>
<p
className="mt-0.5 break-all font-mono text-[11px] text-text"
title={photo.filepath}
>
{photo.filepath
? photo.filepath.replace(
/^\/?nextcloud-users\/[^/]+\/files\//,
'…/',
)
: '—'}
</p>
</div>
{hasGps && (
<a
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
@@ -513,6 +503,21 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
/>
</div>
<div className="text-xs">
<span className="text-text-muted">Path</span>
<p
className="mt-0.5 break-all font-mono text-[11px] text-text"
title={photo.filepath}
>
{photo.filepath
? photo.filepath.replace(
/^\/?nextcloud-users\/[^/]+\/files\//,
'…/',
)
: '—'}
</p>
</div>
<TakenAtEditor
photo={photo}
draft={takenAtDraft}

View File

@@ -3,7 +3,7 @@ import { format } from 'date-fns'
import { cn } from '@/lib/utils'
import {
guessDateFromPath,
toDatetimeLocalValue,
toDateInputValue,
} from '../../lib/guessDateFromPath'
import type { PhotoDetails } from './PhotoInfoPanel'
@@ -78,7 +78,7 @@ export function TakenAtEditor({
<label className="mb-1 block text-text-muted">Date Taken</label>
<div className="flex items-center gap-1.5">
<input
type="datetime-local"
type="date"
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onBlur={() => onCommit()}
@@ -88,7 +88,7 @@ export function TakenAtEditor({
} else if (e.key === 'Escape') {
onDraftChange(
photo.taken_at
? toDatetimeLocalValue(new Date(photo.taken_at))
? toDateInputValue(new Date(photo.taken_at))
: ''
)
e.currentTarget.blur()
@@ -108,7 +108,7 @@ export function TakenAtEditor({
{showSuggestion && guess && (
<button
onClick={() => {
const next = toDatetimeLocalValue(guess.date)
const next = toDateInputValue(guess.date)
onDraftChange(next)
onCommit(next)
}}

View File

@@ -269,11 +269,9 @@ export function guessDateFromPath(filepath: string): DateGuess | null {
return bestFolder
}
/** Format a Date as the `value` of an `<input type="datetime-local">`. */
export function toDatetimeLocalValue(d: Date): string {
/** Format a Date as the `value` of an `<input type="date">` — local-time
* YYYY-MM-DD (ISO 8601 date). */
export function toDateInputValue(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}