Mulimage 2.0 #1
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -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())}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user