From 397d70c0a3a24353180c27aefbf390006918e9be Mon Sep 17 00:00:00 2001 From: Brandon Date: Thu, 12 Mar 2026 06:39:29 -0700 Subject: [PATCH] Add datetime picker to Taken At fields (#388) * Add DateTimePicker component and integrate into PhotoForm for date selection --- src/components/DateTimePicker.tsx | 342 ++++++++++++++++++++++++++++++ src/photo/form/PhotoForm.tsx | 23 ++ 2 files changed, 365 insertions(+) create mode 100644 src/components/DateTimePicker.tsx diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx new file mode 100644 index 00000000..0fc0357a --- /dev/null +++ b/src/components/DateTimePicker.tsx @@ -0,0 +1,342 @@ +'use client'; + +import { + useState, + useRef, + useMemo, + useCallback, +} from 'react'; +import { + format, + parse, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + eachDayOfInterval, + isSameMonth, + isSameDay, + addMonths, + subMonths, + isValid, +} from 'date-fns'; +import { clsx } from 'clsx/lite'; +import useClickInsideOutside from '@/utility/useClickInsideOutside'; +import { useFormStatus } from 'react-dom'; +import { TbCalendar, TbChevronLeft, TbChevronRight } from 'react-icons/tb'; + +const FORMAT_NAIVE = 'yyyy-MM-dd HH:mm:ss'; +const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +export type DateTimePickerType = 'utc' | 'naive'; +type DisplayMode = 'utc' | 'local'; + +// Get short local timezone label, e.g. "EST", "PST", "UTC+5" +const LOCAL_TZ_LABEL = + new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }) + .split(' ').at(-1) ?? 'LOCAL'; + +function parseValue( + value: string, + type: DateTimePickerType, + displayMode: DisplayMode, +): Date | null { + if (!value) return null; + try { + if (type === 'utc') { + const utcDate = new Date(value); + if (!isValid(utcDate)) return null; + if (displayMode === 'local') { + // Return as-is; JS Date local getters will show local time + return utcDate; + } + // Shift UTC components into a local Date so calendar renders UTC time + return new Date( + utcDate.getUTCFullYear(), + utcDate.getUTCMonth(), + utcDate.getUTCDate(), + utcDate.getUTCHours(), + utcDate.getUTCMinutes(), + utcDate.getUTCSeconds(), + ); + } else { + const d = parse(value, FORMAT_NAIVE, new Date()); + return isValid(d) ? d : null; + } + } catch { + return null; + } +} + +function formatValue( + displayDate: Date, + type: DateTimePickerType, + displayMode: DisplayMode, +): string { + if (type === 'utc') { + if (displayMode === 'local') { + // Local Date components → JS converts to UTC in toISOString() + return new Date( + displayDate.getFullYear(), + displayDate.getMonth(), + displayDate.getDate(), + displayDate.getHours(), + displayDate.getMinutes(), + displayDate.getSeconds(), + ).toISOString(); + } + // UTC mode: treat display components as UTC + return new Date(Date.UTC( + displayDate.getFullYear(), + displayDate.getMonth(), + displayDate.getDate(), + displayDate.getHours(), + displayDate.getMinutes(), + displayDate.getSeconds(), + )).toISOString(); + } + return format(displayDate, FORMAT_NAIVE); +} + +export default function DateTimePicker({ + value, + onChange, + type, + readOnly, +}: { + value: string + onChange?: (value: string) => void + type: DateTimePickerType + readOnly?: boolean +}) { + const containerRef = useRef(null); + const { pending } = useFormStatus(); + const isDisabled = readOnly || pending; + + const [isOpen, setIsOpen] = useState(false); + const [viewMonth, setViewMonth] = useState(new Date()); + const [displayMode, setDisplayMode] = useState('utc'); + + const parsedDate = useMemo( + () => parseValue(value, type, displayMode), + [value, type, displayMode], + ); + + useClickInsideOutside({ + htmlElements: [containerRef], + onClickOutside: () => setIsOpen(false), + shouldListenToClicks: isOpen, + }); + + const calendarDays = useMemo(() => { + const start = startOfWeek(startOfMonth(viewMonth)); + const end = endOfWeek(endOfMonth(viewMonth)); + return eachDayOfInterval({ start, end }); + }, [viewMonth]); + + const updateDate = useCallback((newDisplayDate: Date) => { + onChange?.(formatValue(newDisplayDate, type, displayMode)); + }, [onChange, type, displayMode]); + + const handleDayClick = (day: Date) => { + const base = parsedDate ?? new Date(); + updateDate(new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + base.getHours(), + base.getMinutes(), + base.getSeconds(), + )); + }; + + const h = parsedDate?.getHours() ?? 0; + const m = parsedDate?.getMinutes() ?? 0; + const s = parsedDate?.getSeconds() ?? 0; + + return ( +
+ + {isOpen && ( +
+ {/* Month navigation */} +
+ + + {format(viewMonth, 'MMMM yyyy')} + + +
+ + {/* Day headers */} +
+ {DAY_NAMES.map(day => ( +
+ {day} +
+ ))} +
+ + {/* Calendar days */} +
+ {calendarDays.map(day => { + const isCurrentMonth = isSameMonth(day, viewMonth); + const isSelected = + parsedDate !== null && isSameDay(day, parsedDate); + return ( + + ); + })} +
+ + {/* Time */} +
+
+ { + const base = parsedDate ?? new Date(); + updateDate(new Date( + base.getFullYear(), base.getMonth(), base.getDate(), + newH, m, s, + )); + }} + /> + : + { + const base = parsedDate ?? new Date(); + updateDate(new Date( + base.getFullYear(), base.getMonth(), base.getDate(), + h, newM, s, + )); + }} + /> + : + { + const base = parsedDate ?? new Date(); + updateDate(new Date( + base.getFullYear(), base.getMonth(), base.getDate(), + h, m, newS, + )); + }} + /> +
+ {type === 'utc' && ( + + )} +
+
+ )} +
+ ); +} + +function TimeField({ + value, + max, + onChange, +}: { + value: number + max: number + onChange: (value: number) => void +}) { + const formatted = useMemo(() => String(value).padStart(2, '0'), [value]); + const [draft, setDraft] = useState(null); + + return ( + { + const val = e.target.value; + if (!/^\d{0,2}$/.test(val)) return; + setDraft(val); + const n = parseInt(val, 10); + if (!isNaN(n) && n >= 0 && n <= max) { + onChange(n); + } + }} + onFocus={() => setDraft(formatted)} + onBlur={() => setDraft(null)} + className="w-9! min-h-0! text-center px-1! py-1! text-xs" + /> + ); +} diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 3f05f5f7..26466ced 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -72,6 +72,7 @@ import { Albums } from '@/album'; import FieldsetAlbum from '@/album/FieldsetAlbum'; import Form from 'next/form'; import { useRouter, useSearchParams } from 'next/navigation'; +import DateTimePicker from '@/components/DateTimePicker'; const THUMBNAIL_SIZE = 300; @@ -659,6 +660,28 @@ export default function PhotoForm({ formData, )} />; + case 'takenAt': + return } + />; + case 'takenAtNaive': + return } + />; case 'favorite': return