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:
@@ -64,7 +64,11 @@ export function BulkTakenAtEditor({
|
|||||||
|
|
||||||
const handleApplyUniform = () => {
|
const handleApplyUniform = () => {
|
||||||
if (!uniformDraft) return
|
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
|
if (Number.isNaN(parsed.getTime())) return
|
||||||
onApplyUniform(parsed.toISOString())
|
onApplyUniform(parsed.toISOString())
|
||||||
}
|
}
|
||||||
@@ -74,7 +78,7 @@ export function BulkTakenAtEditor({
|
|||||||
{/* Apply-one row */}
|
{/* Apply-one row */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Input
|
<Input
|
||||||
type="datetime-local"
|
type="date"
|
||||||
value={uniformDraft}
|
value={uniformDraft}
|
||||||
onChange={(e) => setUniformDraft(e.target.value)}
|
onChange={(e) => setUniformDraft(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
COLOR_LABEL_OPTIONS,
|
COLOR_LABEL_OPTIONS,
|
||||||
type ColorLabel,
|
type ColorLabel,
|
||||||
} from '../../constants/colorLabels'
|
} from '../../constants/colorLabels'
|
||||||
import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
|
import { toDateInputValue } from '../../lib/guessDateFromPath'
|
||||||
import { TagsEditor } from './TagsEditor'
|
import { TagsEditor } from './TagsEditor'
|
||||||
import { TakenAtEditor } from './TakenAtEditor'
|
import { TakenAtEditor } from './TakenAtEditor'
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
setFilenameDraft(photo?.filename ?? '')
|
setFilenameDraft(photo?.filename ?? '')
|
||||||
setNotesDraft(photo?.user_notes ?? '')
|
setNotesDraft(photo?.user_notes ?? '')
|
||||||
setTakenAtDraft(
|
setTakenAtDraft(
|
||||||
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
|
photo?.taken_at ? toDateInputValue(new Date(photo.taken_at)) : ''
|
||||||
)
|
)
|
||||||
}, [
|
}, [
|
||||||
photo?.id,
|
photo?.id,
|
||||||
@@ -353,26 +353,32 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
updateMutation.mutate({ user_notes: next || null })
|
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
|
* 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
|
* 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
|
* 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 commitTakenAt = (rawValue?: string) => {
|
||||||
const source = rawValue ?? takenAtDraft
|
const source = rawValue ?? takenAtDraft
|
||||||
if (!source) return
|
if (!source) return
|
||||||
const parsed = new Date(source)
|
const [y, m, d] = source.split('-').map((n) => Number(n))
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) {
|
||||||
toast.error('Invalid date', 'Could not parse the value')
|
toast.error('Invalid date', 'Could not parse the value')
|
||||||
setTakenAtDraft(
|
setTakenAtDraft(
|
||||||
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
|
photo?.taken_at ? toDateInputValue(new Date(photo.taken_at)) : ''
|
||||||
)
|
)
|
||||||
return
|
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()
|
const iso = parsed.toISOString()
|
||||||
if (photo?.taken_at && new Date(photo.taken_at).toISOString() === iso) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ taken_at: iso },
|
{ taken_at: iso },
|
||||||
{
|
{
|
||||||
@@ -383,7 +389,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
)
|
)
|
||||||
setTakenAtDraft(
|
setTakenAtDraft(
|
||||||
photo?.taken_at
|
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>
|
</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 && (
|
{hasGps && (
|
||||||
<a
|
<a
|
||||||
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
|
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>
|
||||||
|
|
||||||
|
<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
|
<TakenAtEditor
|
||||||
photo={photo}
|
photo={photo}
|
||||||
draft={takenAtDraft}
|
draft={takenAtDraft}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { format } from 'date-fns'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
guessDateFromPath,
|
guessDateFromPath,
|
||||||
toDatetimeLocalValue,
|
toDateInputValue,
|
||||||
} from '../../lib/guessDateFromPath'
|
} from '../../lib/guessDateFromPath'
|
||||||
import type { PhotoDetails } from './PhotoInfoPanel'
|
import type { PhotoDetails } from './PhotoInfoPanel'
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ export function TakenAtEditor({
|
|||||||
<label className="mb-1 block text-text-muted">Date Taken</label>
|
<label className="mb-1 block text-text-muted">Date Taken</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="date"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => onDraftChange(e.target.value)}
|
onChange={(e) => onDraftChange(e.target.value)}
|
||||||
onBlur={() => onCommit()}
|
onBlur={() => onCommit()}
|
||||||
@@ -88,7 +88,7 @@ export function TakenAtEditor({
|
|||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
onDraftChange(
|
onDraftChange(
|
||||||
photo.taken_at
|
photo.taken_at
|
||||||
? toDatetimeLocalValue(new Date(photo.taken_at))
|
? toDateInputValue(new Date(photo.taken_at))
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
e.currentTarget.blur()
|
e.currentTarget.blur()
|
||||||
@@ -108,7 +108,7 @@ export function TakenAtEditor({
|
|||||||
{showSuggestion && guess && (
|
{showSuggestion && guess && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = toDatetimeLocalValue(guess.date)
|
const next = toDateInputValue(guess.date)
|
||||||
onDraftChange(next)
|
onDraftChange(next)
|
||||||
onCommit(next)
|
onCommit(next)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -269,11 +269,9 @@ export function guessDateFromPath(filepath: string): DateGuess | null {
|
|||||||
return bestFolder
|
return bestFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a Date as the `value` of an `<input type="datetime-local">`. */
|
/** Format a Date as the `value` of an `<input type="date">` — local-time
|
||||||
export function toDatetimeLocalValue(d: Date): string {
|
* YYYY-MM-DD (ISO 8601 date). */
|
||||||
|
export function toDateInputValue(d: Date): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return (
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
||||||
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user