Spaces:
Running
Running
| 'use client' | |
| import React, { useState, useEffect, useRef } from 'react' | |
| import { motion, AnimatePresence } from 'framer-motion' | |
| import Window from './Window' | |
| import { Globe, Clock as ClockIcon, Type, Plus } from 'lucide-react' | |
| interface ClockProps { | |
| onClose: () => void | |
| onMinimize?: () => void | |
| onMaximize?: () => void | |
| onFocus?: () => void | |
| zIndex?: number | |
| } | |
| // Helper to get smooth time | |
| const useSmoothTime = () => { | |
| const [time, setTime] = useState(new Date()) | |
| useEffect(() => { | |
| let frameId: number | |
| const update = () => { | |
| setTime(new Date()) | |
| frameId = requestAnimationFrame(update) | |
| } | |
| update() | |
| return () => cancelAnimationFrame(frameId) | |
| }, []) | |
| return time | |
| } | |
| const AnalogFace = ({ time, city, offset, isSmall = false }: { time: Date, city?: string, offset?: number, isSmall?: boolean }) => { | |
| // Calculate time with offset if provided | |
| const displayTime = offset !== undefined | |
| ? new Date(time.getTime() + (time.getTimezoneOffset() * 60000) + (3600000 * offset)) | |
| : time | |
| const seconds = displayTime.getSeconds() + displayTime.getMilliseconds() / 1000 | |
| const minutes = displayTime.getMinutes() + seconds / 60 | |
| const hours = displayTime.getHours() % 12 + minutes / 60 | |
| return ( | |
| <div className={`relative flex flex-col items-center ${isSmall ? 'gap-2' : 'gap-8'}`}> | |
| <div className={`relative ${isSmall ? 'w-32 h-32' : 'w-64 h-64'} rounded-full bg-[#1e1e1e] shadow-[inset_0_0_20px_rgba(0,0,0,0.5)] border-[3px] border-[#333]`}> | |
| {/* Clock Face Markings */} | |
| {[...Array(12)].map((_, i) => ( | |
| <div | |
| key={i} | |
| className="absolute w-full h-full left-0 top-0" | |
| style={{ transform: `rotate(${i * 30}deg)` }} | |
| > | |
| <div className={`absolute left-1/2 -translate-x-1/2 top-2 bg-gray-400 ${isSmall ? 'w-0.5 h-2' : 'w-1 h-3' | |
| }`} /> | |
| {!isSmall && ( | |
| <span | |
| className="absolute left-1/2 -translate-x-1/2 top-6 text-xl font-medium text-gray-300" | |
| style={{ transform: `rotate(-${i * 30}deg)` }} | |
| > | |
| {i === 0 ? 12 : i} | |
| </span> | |
| )} | |
| </div> | |
| ))} | |
| {/* Hands Container */} | |
| <div className="absolute inset-0"> | |
| {/* Hour Hand */} | |
| <div | |
| className="absolute left-1/2 top-1/2 bg-white rounded-full origin-bottom shadow-lg z-10" | |
| style={{ | |
| width: isSmall ? '3px' : '6px', | |
| height: isSmall ? '25%' : '25%', | |
| transform: `translate(-50%, -100%) rotate(${hours * 30}deg)`, | |
| }} | |
| /> | |
| {/* Minute Hand */} | |
| <div | |
| className="absolute left-1/2 top-1/2 bg-white rounded-full origin-bottom shadow-lg z-10" | |
| style={{ | |
| width: isSmall ? '2px' : '4px', | |
| height: isSmall ? '35%' : '38%', | |
| transform: `translate(-50%, -100%) rotate(${minutes * 6}deg)`, | |
| }} | |
| /> | |
| {/* Second Hand (Orange) */} | |
| <div | |
| className="absolute left-1/2 top-1/2 bg-orange-500 rounded-full origin-bottom z-20" | |
| style={{ | |
| width: isSmall ? '1px' : '2px', | |
| height: isSmall ? '40%' : '45%', | |
| transform: `translate(-50%, -100%) rotate(${seconds * 6}deg)`, | |
| }} | |
| > | |
| {/* Tail of second hand */} | |
| <div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-[20%] w-full h-[20%] bg-orange-500" /> | |
| </div> | |
| {/* Center Cap */} | |
| <div className={`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black border-2 border-orange-500 z-30 ${isSmall ? 'w-1.5 h-1.5' : 'w-3 h-3' | |
| }`} /> | |
| </div> | |
| </div> | |
| {/* Labels */} | |
| <div className="text-center"> | |
| <div className={`${isSmall ? 'text-sm' : 'text-2xl'} font-medium text-white`}> | |
| {city || 'Local Time'} | |
| </div> | |
| <div className={`${isSmall ? 'text-xs' : 'text-lg'} text-gray-400 font-mono mt-1`}> | |
| {offset !== undefined | |
| ? (offset === 0 ? 'Today' : `${offset > 0 ? '+' : ''}${offset}HRS`) | |
| : displayTime.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }) | |
| } | |
| </div> | |
| {!isSmall && ( | |
| <div className="text-4xl font-light text-white mt-4 tracking-wider"> | |
| {displayTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // List of available cities with offsets | |
| const AVAILABLE_CITIES = [ | |
| { city: 'San Francisco', offset: -8, region: 'USA' }, | |
| { city: 'New York', offset: -5, region: 'USA' }, | |
| { city: 'London', offset: 0, region: 'UK' }, | |
| { city: 'Paris', offset: 1, region: 'Europe' }, | |
| { city: 'Berlin', offset: 1, region: 'Europe' }, | |
| { city: 'Moscow', offset: 3, region: 'Russia' }, | |
| { city: 'Dubai', offset: 4, region: 'UAE' }, | |
| { city: 'Mumbai', offset: 5.5, region: 'India' }, | |
| { city: 'Singapore', offset: 8, region: 'Asia' }, | |
| { city: 'Tokyo', offset: 9, region: 'Japan' }, | |
| { city: 'Sydney', offset: 11, region: 'Australia' }, | |
| { city: 'Auckland', offset: 13, region: 'New Zealand' }, | |
| { city: 'Honolulu', offset: -10, region: 'USA' }, | |
| { city: 'Rio de Janeiro', offset: -3, region: 'Brazil' }, | |
| ] | |
| const ClockContent = () => { | |
| const time = useSmoothTime() | |
| const [activeTab, setActiveTab] = useState<'world' | 'analog' | 'digital'>('world') | |
| const [showAddCity, setShowAddCity] = useState(false) | |
| const [searchQuery, setSearchQuery] = useState('') | |
| const [worldClocks, setWorldClocks] = useState([ | |
| { city: 'Cupertino', offset: -8 }, | |
| { city: 'New York', offset: -5 }, | |
| { city: 'London', offset: 0 }, | |
| { city: 'Tokyo', offset: 9 }, | |
| ]) | |
| const tabs = [ | |
| { id: 'world', label: 'World Clock', icon: Globe }, | |
| { id: 'analog', label: 'Analog', icon: ClockIcon }, | |
| { id: 'digital', label: 'Digital', icon: Type }, | |
| ] | |
| const filteredCities = AVAILABLE_CITIES.filter(c => | |
| c.city.toLowerCase().includes(searchQuery.toLowerCase()) && | |
| !worldClocks.some(wc => wc.city === c.city) | |
| ) | |
| const addCity = (city: typeof AVAILABLE_CITIES[0]) => { | |
| setWorldClocks([...worldClocks, { city: city.city, offset: city.offset }]) | |
| setShowAddCity(false) | |
| setSearchQuery('') | |
| } | |
| return ( | |
| <div className="flex flex-col h-full text-white relative"> | |
| {/* macOS-style Toolbar */} | |
| <div className="flex items-center justify-center pt-4 pb-6 border-b border-white/10 bg-[#252525]/50"> | |
| <div className="flex bg-black/20 p-1 rounded-lg backdrop-blur-md"> | |
| {tabs.map((tab) => { | |
| const Icon = tab.icon | |
| const isActive = activeTab === tab.id | |
| return ( | |
| <button | |
| key={tab.id} | |
| onClick={() => setActiveTab(tab.id as any)} | |
| className={`relative px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200 flex items-center gap-2 ${isActive ? 'text-white' : 'text-gray-400 hover:text-gray-200' | |
| }`} | |
| > | |
| {isActive && ( | |
| <motion.div | |
| layoutId="activeTab" | |
| className="absolute inset-0 bg-[#3a3a3a] rounded-md shadow-sm" | |
| transition={{ type: "spring", bounce: 0.2, duration: 0.6 }} | |
| /> | |
| )} | |
| <span className="relative z-10 flex items-center gap-2"> | |
| <Icon size={14} /> | |
| {tab.label} | |
| </span> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| {/* Content Area */} | |
| <div className="flex-1 overflow-y-auto p-8 [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2"> | |
| <AnimatePresence mode="wait"> | |
| {activeTab === 'world' && !showAddCity && ( | |
| <motion.div | |
| key="world" | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -10 }} | |
| className="grid grid-cols-2 gap-8 justify-items-center" | |
| > | |
| {worldClocks.map((clock) => ( | |
| <div key={clock.city} className="relative group"> | |
| <AnalogFace | |
| time={time} | |
| city={clock.city} | |
| offset={clock.offset} | |
| isSmall={true} | |
| /> | |
| <button | |
| onClick={() => setWorldClocks(worldClocks.filter(c => c.city !== clock.city))} | |
| className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg" | |
| > | |
| <Plus size={12} className="rotate-45" /> | |
| </button> | |
| </div> | |
| ))} | |
| {/* Add Button */} | |
| <button | |
| onClick={() => setShowAddCity(true)} | |
| className="w-32 h-32 rounded-full border-2 border-dashed border-gray-600 flex flex-col items-center justify-center text-gray-500 hover:text-orange-500 hover:border-orange-500 transition-colors group" | |
| > | |
| <Plus size={32} className="group-hover:scale-110 transition-transform" /> | |
| <span className="text-xs mt-2 font-medium">Add City</span> | |
| </button> | |
| </motion.div> | |
| )} | |
| {activeTab === 'world' && showAddCity && ( | |
| <motion.div | |
| key="add-city" | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.95 }} | |
| className="flex flex-col h-full max-w-md mx-auto" | |
| > | |
| <div className="flex items-center gap-2 mb-4"> | |
| <button | |
| onClick={() => setShowAddCity(false)} | |
| className="p-2 hover:bg-white/10 rounded-full transition-colors" | |
| > | |
| <Plus size={20} className="rotate-45" /> | |
| </button> | |
| <input | |
| type="text" | |
| placeholder="Search city..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="flex-1 bg-white/10 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-orange-500 transition-colors" | |
| autoFocus | |
| /> | |
| </div> | |
| <div className="flex-1 overflow-y-auto space-y-2 pr-2 [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2"> | |
| {filteredCities.map((city) => ( | |
| <button | |
| key={city.city} | |
| onClick={() => addCity(city)} | |
| className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-white/10 transition-colors group text-left" | |
| > | |
| <div> | |
| <div className="font-medium text-white">{city.city}</div> | |
| <div className="text-xs text-gray-400">{city.region}</div> | |
| </div> | |
| <div className="text-sm font-mono text-gray-500 group-hover:text-orange-400"> | |
| UTC{city.offset >= 0 ? '+' : ''}{city.offset} | |
| </div> | |
| </button> | |
| ))} | |
| {filteredCities.length === 0 && ( | |
| <div className="text-center text-gray-500 mt-8"> | |
| No cities found | |
| </div> | |
| )} | |
| </div> | |
| </motion.div> | |
| )} | |
| {activeTab === 'analog' && ( | |
| <motion.div | |
| key="analog" | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 1.05 }} | |
| className="flex items-center justify-center h-full" | |
| > | |
| <AnalogFace time={time} /> | |
| </motion.div> | |
| )} | |
| {activeTab === 'digital' && ( | |
| <motion.div | |
| key="digital" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="flex flex-col items-center justify-center h-full" | |
| > | |
| <div className="text-[8rem] leading-none font-light tracking-tighter text-white tabular-nums"> | |
| {time.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' })} | |
| </div> | |
| <div className="text-4xl font-light text-orange-500 mt-4 tabular-nums"> | |
| {time.getSeconds().toString().padStart(2, '0')} | |
| </div> | |
| <div className="text-xl text-gray-400 mt-8 font-medium tracking-wide uppercase"> | |
| {time.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export function Clock({ onClose, onMinimize, onMaximize, onFocus, zIndex }: ClockProps) { | |
| return ( | |
| <Window | |
| id="clock" | |
| title="Clock" | |
| isOpen={true} | |
| onClose={onClose} | |
| onMinimize={onMinimize} | |
| onMaximize={onMaximize} | |
| onFocus={onFocus} | |
| zIndex={zIndex} | |
| width={600} | |
| height={500} | |
| x={window.innerWidth / 2 - 300} | |
| y={window.innerHeight / 2 - 250} | |
| darkMode={true} | |
| className="clock-app-window !bg-[#1e1e1e]/80 !backdrop-blur-2xl border border-white/10 shadow-2xl !rounded-xl overflow-hidden" | |
| contentClassName="!bg-transparent" | |
| headerClassName="!bg-transparent border-b border-white/5" | |
| > | |
| <ClockContent /> | |
| </Window> | |
| ) | |
| } |