Add datetime picker to Taken At fields (#388)

* Add DateTimePicker component and integrate into PhotoForm for date selection
This commit is contained in:
Brandon 2026-03-12 06:39:29 -07:00 committed by GitHub
parent ac96350849
commit 397d70c0a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 365 additions and 0 deletions

View File

@ -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<HTMLDivElement>(null);
const { pending } = useFormStatus();
const isDisabled = readOnly || pending;
const [isOpen, setIsOpen] = useState(false);
const [viewMonth, setViewMonth] = useState(new Date());
const [displayMode, setDisplayMode] = useState<DisplayMode>('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 (
<div ref={containerRef} className="relative self-start">
<button
type="button"
tabIndex={-1}
disabled={isDisabled}
onClick={() => {
if (isDisabled) return;
if (!isOpen && parsedDate) {
setViewMonth(parsedDate);
}
setIsOpen(o => !o);
}}
className="h-9"
>
<TbCalendar size={16} />
</button>
{isOpen && (
<div className={clsx(
'component-surface shadow-lg dark:shadow-xl',
'absolute right-0 top-full mt-1 z-10',
'p-3 w-60',
'animate-fade-in-from-top',
)}>
{/* Month navigation */}
<div className="flex items-center justify-between mb-2">
<button
type="button"
className="link"
onClick={() => setViewMonth(subMonths(viewMonth, 1))}
>
<TbChevronLeft size={16} />
</button>
<span className="text-sm font-medium">
{format(viewMonth, 'MMMM yyyy')}
</span>
<button
type="button"
className="link"
onClick={() => setViewMonth(addMonths(viewMonth, 1))}
>
<TbChevronRight size={16} />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 mb-1">
{DAY_NAMES.map(day => (
<div
key={day}
className="text-center text-xs text-dim py-0.5"
>
{day}
</div>
))}
</div>
{/* Calendar days */}
<div className="grid grid-cols-7">
{calendarDays.map(day => {
const isCurrentMonth = isSameMonth(day, viewMonth);
const isSelected =
parsedDate !== null && isSameDay(day, parsedDate);
return (
<button
key={day.toISOString()}
type="button"
onClick={() => handleDayClick(day)}
className={clsx(
'link justify-center py-1 px-0! w-full rounded-sm!',
!isCurrentMonth && 'text-extra-dim',
isCurrentMonth && !isSelected &&
'hover:bg-gray-100! dark:hover:bg-gray-800!',
isSelected &&
// eslint-disable-next-line max-len
'bg-gray-900! dark:bg-gray-100! text-white! dark:text-black!',
)}
>
{format(day, 'd')}
</button>
);
})}
</div>
{/* Time */}
<div className={clsx(
'flex items-center justify-between',
'mt-2 pt-2 border-t border-gray-200 dark:border-gray-700',
)}>
<div className="flex items-center gap-1">
<TimeField
value={h}
max={23}
onChange={newH => {
const base = parsedDate ?? new Date();
updateDate(new Date(
base.getFullYear(), base.getMonth(), base.getDate(),
newH, m, s,
));
}}
/>
<span className="text-dim select-none">:</span>
<TimeField
value={m}
max={59}
onChange={newM => {
const base = parsedDate ?? new Date();
updateDate(new Date(
base.getFullYear(), base.getMonth(), base.getDate(),
h, newM, s,
));
}}
/>
<span className="text-dim select-none">:</span>
<TimeField
value={s}
max={59}
onChange={newS => {
const base = parsedDate ?? new Date();
updateDate(new Date(
base.getFullYear(), base.getMonth(), base.getDate(),
h, m, newS,
));
}}
/>
</div>
{type === 'utc' && (
<button
type="button"
onClick={() => {
const newMode: DisplayMode =
displayMode === 'utc' ? 'local' : 'utc';
const newParsed = parseValue(value, type, newMode);
if (newParsed) setViewMonth(newParsed);
setDisplayMode(newMode);
}}
className={clsx(
'shrink-0 text-xs px-1.5! py-1!',
'border font-mono',
displayMode === 'local'
? 'border-gray-900 dark:border-gray-100 text-main'
: 'border-gray-300 dark:border-gray-600 text-dim',
)}
>
{displayMode === 'local' ? LOCAL_TZ_LABEL : 'UTC'}
</button>
)}
</div>
</div>
)}
</div>
);
}
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<string | null>(null);
return (
<input
type="text"
inputMode="numeric"
value={draft ?? formatted}
onChange={e => {
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"
/>
);
}

View File

@ -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 <FieldsetWithStatus
key={key}
{...fieldProps}
accessory={<DateTimePicker
value={formData.takenAt ?? ''}
onChange={fieldProps.onChange}
type="utc"
readOnly={fieldProps.readOnly}
/>}
/>;
case 'takenAtNaive':
return <FieldsetWithStatus
key={key}
{...fieldProps}
accessory={<DateTimePicker
value={formData.takenAtNaive ?? ''}
onChange={fieldProps.onChange}
type="naive"
readOnly={fieldProps.readOnly}
/>}
/>;
case 'favorite':
return <FieldsetFavs
key={key}