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 FieldsetAlbum from '@/album/FieldsetAlbum';
|
||||||
import Form from 'next/form';
|
import Form from 'next/form';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import DateTimePicker from '@/components/DateTimePicker';
|
||||||
|
|
||||||
const THUMBNAIL_SIZE = 300;
|
const THUMBNAIL_SIZE = 300;
|
||||||
|
|
||||||
@ -659,6 +660,28 @@ export default function PhotoForm({
|
|||||||
formData,
|
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':
|
case 'favorite':
|
||||||
return <FieldsetFavs
|
return <FieldsetFavs
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user