279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Clock } from 'lucide-react';
|
|
import { format } from 'date-fns';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
interface ClockTimePickerProps {
|
|
time: string; // HH:mm format (e.g., "09:00")
|
|
setTime: (time: string) => void;
|
|
label?: string;
|
|
isDark?: boolean;
|
|
}
|
|
|
|
export function ClockTimePicker({ time, setTime, label, isDark = false }: ClockTimePickerProps) {
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
const [mode, setMode] = React.useState<'hour' | 'minute'>('hour');
|
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
// Parse time string to hours and minutes
|
|
const [hours, minutes] = React.useMemo(() => {
|
|
if (!time) return [9, 0];
|
|
const parts = time.split(':').map(Number);
|
|
return [parts[0] || 9, parts[1] || 0];
|
|
}, [time]);
|
|
|
|
// Convert to 12-hour format for display
|
|
const displayHours = hours % 12 || 12;
|
|
const ampm = hours >= 12 ? 'PM' : 'AM';
|
|
|
|
// Close picker when clicking outside
|
|
React.useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
setMode('hour');
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
// Handle hour selection
|
|
const handleHourClick = (selectedHour: number) => {
|
|
const newHours = ampm === 'PM' && selectedHour !== 12
|
|
? selectedHour + 12
|
|
: ampm === 'AM' && selectedHour === 12
|
|
? 0
|
|
: selectedHour;
|
|
|
|
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
|
setMode('minute');
|
|
};
|
|
|
|
// Handle minute selection
|
|
const handleMinuteClick = (selectedMinute: number) => {
|
|
setTime(`${hours.toString().padStart(2, '0')}:${selectedMinute.toString().padStart(2, '0')}`);
|
|
setIsOpen(false);
|
|
setMode('hour');
|
|
};
|
|
|
|
// Generate hour numbers (1-12)
|
|
const hourNumbers = Array.from({ length: 12 }, (_, i) => i + 1);
|
|
|
|
// Generate minute numbers (0, 15, 30, 45 or 0-59)
|
|
const minuteNumbers = Array.from({ length: 12 }, (_, i) => i * 5); // 0, 5, 10, 15, ..., 55
|
|
|
|
// Calculate position for clock numbers
|
|
const getClockPosition = (index: number, total: number, radius: number = 90) => {
|
|
const angle = (index * 360) / total - 90; // Start from top (-90 degrees)
|
|
const radian = (angle * Math.PI) / 180;
|
|
const x = Math.cos(radian) * radius;
|
|
const y = Math.sin(radian) * radius;
|
|
return { x, y };
|
|
};
|
|
|
|
// Format display time
|
|
const displayTime = time
|
|
? `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`
|
|
: 'Select time';
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{label && (
|
|
<label className={cn(
|
|
"text-sm font-semibold",
|
|
isDark ? "text-gray-300" : "text-gray-700"
|
|
)}>
|
|
{label}
|
|
</label>
|
|
)}
|
|
<div className="relative" ref={wrapperRef}>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={cn(
|
|
"w-full justify-start text-left font-normal h-12 text-base",
|
|
!time && "text-muted-foreground",
|
|
isDark
|
|
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
|
|
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
|
|
)}
|
|
>
|
|
<Clock className="mr-2 h-5 w-5" />
|
|
{displayTime}
|
|
</Button>
|
|
{isOpen && (
|
|
<div className={cn(
|
|
"absolute z-[9999] mt-1 rounded-lg shadow-lg border p-4 -translate-y-1",
|
|
isDark
|
|
? "bg-gray-800 border-gray-700"
|
|
: "bg-white border-gray-200"
|
|
)}>
|
|
{/* Mode selector */}
|
|
<div className="flex gap-2 mb-4">
|
|
<button
|
|
onClick={() => setMode('hour')}
|
|
className={cn(
|
|
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
|
|
mode === 'hour'
|
|
? isDark
|
|
? "bg-blue-600 text-white"
|
|
: "bg-blue-600 text-white"
|
|
: isDark
|
|
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
)}
|
|
>
|
|
Hour
|
|
</button>
|
|
<button
|
|
onClick={() => setMode('minute')}
|
|
className={cn(
|
|
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
|
|
mode === 'minute'
|
|
? isDark
|
|
? "bg-blue-600 text-white"
|
|
: "bg-blue-600 text-white"
|
|
: isDark
|
|
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
)}
|
|
>
|
|
Minute
|
|
</button>
|
|
</div>
|
|
|
|
{/* Clock face */}
|
|
<div className="relative w-64 h-64 mx-auto my-4">
|
|
{/* Clock circle */}
|
|
<div className={cn(
|
|
"absolute inset-0 rounded-full border-2",
|
|
isDark ? "border-gray-600" : "border-gray-300"
|
|
)} />
|
|
|
|
{/* Center dot */}
|
|
<div className={cn(
|
|
"absolute top-1/2 left-1/2 w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 z-10",
|
|
isDark ? "bg-gray-400" : "bg-gray-600"
|
|
)} />
|
|
|
|
{/* Hour numbers */}
|
|
{mode === 'hour' && hourNumbers.map((hour, index) => {
|
|
const { x, y } = getClockPosition(index, 12, 90);
|
|
const isSelected = displayHours === hour;
|
|
return (
|
|
<button
|
|
key={hour}
|
|
type="button"
|
|
onClick={() => handleHourClick(hour)}
|
|
className={cn(
|
|
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
|
|
isSelected
|
|
? isDark
|
|
? "bg-blue-600 text-white scale-110 shadow-lg"
|
|
: "bg-blue-600 text-white scale-110 shadow-lg"
|
|
: isDark
|
|
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
|
|
)}
|
|
style={{
|
|
left: `calc(50% + ${x}px)`,
|
|
top: `calc(50% + ${y}px)`,
|
|
transform: 'translate(-50%, -50%)',
|
|
}}
|
|
>
|
|
{hour}
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{/* Minute numbers */}
|
|
{mode === 'minute' && minuteNumbers.map((minute, index) => {
|
|
const { x, y } = getClockPosition(index, 12, 90);
|
|
const isSelected = minutes === minute;
|
|
return (
|
|
<button
|
|
key={minute}
|
|
type="button"
|
|
onClick={() => handleMinuteClick(minute)}
|
|
className={cn(
|
|
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
|
|
isSelected
|
|
? isDark
|
|
? "bg-blue-600 text-white scale-110 shadow-lg"
|
|
: "bg-blue-600 text-white scale-110 shadow-lg"
|
|
: isDark
|
|
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
|
|
)}
|
|
style={{
|
|
left: `calc(50% + ${x}px)`,
|
|
top: `calc(50% + ${y}px)`,
|
|
transform: 'translate(-50%, -50%)',
|
|
}}
|
|
>
|
|
{minute}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* AM/PM toggle */}
|
|
<div className="flex gap-2 mt-4 justify-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const newHours = ampm === 'PM' ? hours - 12 : hours;
|
|
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
|
}}
|
|
className={cn(
|
|
"px-4 py-2 rounded text-sm font-medium transition-colors",
|
|
ampm === 'AM'
|
|
? isDark
|
|
? "bg-blue-600 text-white"
|
|
: "bg-blue-600 text-white"
|
|
: isDark
|
|
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
)}
|
|
>
|
|
AM
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const newHours = ampm === 'AM' ? hours + 12 : hours;
|
|
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
|
}}
|
|
className={cn(
|
|
"px-4 py-2 rounded text-sm font-medium transition-colors",
|
|
ampm === 'PM'
|
|
? isDark
|
|
? "bg-blue-600 text-white"
|
|
: "bg-blue-600 text-white"
|
|
: isDark
|
|
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
)}
|
|
>
|
|
PM
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|