Add datetime picker to Taken At fields (#388)
* Add DateTimePicker component and integrate into PhotoForm for date selection
This commit is contained in:
parent
ac96350849
commit
397d70c0a3
342
src/components/DateTimePicker.tsx
Normal file
342
src/components/DateTimePicker.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user