|
| 1 | +// src/App.tsx |
| 2 | +/** |
| 3 | + * @license |
| 4 | + * SPDX-License-Identifier: Apache-2.0 |
| 5 | + */ |
| 6 | + |
| 7 | +import { useState, useEffect, useCallback } from 'react'; |
| 8 | +import { motion, AnimatePresence } from 'motion/react'; |
| 9 | +import { Search, MapPin, Wind, Droplets, Thermometer, Info, Sparkles, Navigation, Clock } from 'lucide-react'; |
| 10 | +import { format } from 'date-fns'; |
| 11 | +import { WeatherIcon } from './components/WeatherIcons'; |
| 12 | +import { searchLocation, getWeatherData, LocationData, WeatherData } from './services/weatherService'; |
| 13 | +import { getWeatherInsights, WeatherInsight } from './services/aiService'; |
| 14 | + |
| 15 | +export default function App() { |
| 16 | + const [query, setQuery] = useState(''); |
| 17 | + const [results, setResults] = useState<LocationData[]>([]); |
| 18 | + const [selectedLocation, setSelectedLocation] = useState<LocationData | null>(null); |
| 19 | + const [weather, setWeather] = useState<WeatherData | null>(null); |
| 20 | + const [insights, setInsights] = useState<WeatherInsight | null>(null); |
| 21 | + const [loading, setLoading] = useState(false); |
| 22 | + const [searching, setSearching] = useState(false); |
| 23 | + const [currentTime, setCurrentTime] = useState(new Date()); |
| 24 | + |
| 25 | + useEffect(() => { |
| 26 | + const timer = setInterval(() => setCurrentTime(new Date()), 60000); |
| 27 | + return () => clearInterval(timer); |
| 28 | + }, []); |
| 29 | + |
| 30 | + // Initialize with user location if possible |
| 31 | + useEffect(() => { |
| 32 | + if (navigator.geolocation) { |
| 33 | + navigator.geolocation.getCurrentPosition(async (position) => { |
| 34 | + const { latitude, longitude } = position.coords; |
| 35 | + try { |
| 36 | + setLoading(true); |
| 37 | + const data = await getWeatherData(latitude, longitude); |
| 38 | + setWeather(data); |
| 39 | + setSelectedLocation({ id: 0, name: "Current Location", latitude, longitude, country: "" }); |
| 40 | + const aiInsights = await getWeatherInsights("your current location", data); |
| 41 | + setInsights(aiInsights); |
| 42 | + } catch (error) { |
| 43 | + console.error("Location init error:", error); |
| 44 | + } finally { |
| 45 | + setLoading(false); |
| 46 | + } |
| 47 | + }); |
| 48 | + } |
| 49 | + }, []); |
| 50 | + |
| 51 | + const handleSearch = useCallback(async (val: string) => { |
| 52 | + setQuery(val); |
| 53 | + if (val.length > 2) { |
| 54 | + setSearching(true); |
| 55 | + const res = await searchLocation(val); |
| 56 | + setResults(res); |
| 57 | + setSearching(false); |
| 58 | + } else { |
| 59 | + setResults([]); |
| 60 | + } |
| 61 | + }, []); |
| 62 | + |
| 63 | + const selectLocation = async (loc: LocationData) => { |
| 64 | + setQuery(''); |
| 65 | + setResults([]); |
| 66 | + setSelectedLocation(loc); |
| 67 | + setLoading(true); |
| 68 | + try { |
| 69 | + const data = await getWeatherData(loc.latitude, loc.longitude); |
| 70 | + setWeather(data); |
| 71 | + const aiInsights = await getWeatherInsights(loc.name, data); |
| 72 | + setInsights(aiInsights); |
| 73 | + } catch (err) { |
| 74 | + console.error(err); |
| 75 | + } finally { |
| 76 | + setLoading(false); |
| 77 | + } |
| 78 | + }; |
| 79 | + |
| 80 | + return ( |
| 81 | + <div className="h-screen bg-[#0A0C10] text-white font-sans flex flex-col overflow-hidden selection:bg-blue-500/30"> |
| 82 | + {/* Header */} |
| 83 | + <header className="flex items-center justify-between px-8 py-6 border-b border-gray-800 shrink-0"> |
| 84 | + <div className="flex items-center gap-3"> |
| 85 | + <div className="w-8 h-8 bg-gradient-to-tr from-blue-600 to-cyan-400 rounded-full blur-[1px]"></div> |
| 86 | + <span className="text-xl font-semibold tracking-tight text-white">SkyCast <span className="text-blue-500">AI</span></span> |
| 87 | + </div> |
| 88 | + |
| 89 | + <div className="flex items-center gap-8"> |
| 90 | + <div className="hidden md:flex flex-col items-end"> |
| 91 | + <span className="text-[10px] uppercase tracking-widest text-gray-500">Local Environment</span> |
| 92 | + <span className="text-sm font-medium">{format(currentTime, 'h:mm a')} • {selectedLocation?.name || 'Awaiting Input'}</span> |
| 93 | + </div> |
| 94 | + |
| 95 | + <div className="h-10 w-[1px] bg-gray-800 hidden md:block"></div> |
| 96 | + |
| 97 | + <div className="relative w-48 md:w-64"> |
| 98 | + <div className="relative flex items-center"> |
| 99 | + <Search className="absolute left-3 w-4 h-4 text-gray-500" /> |
| 100 | + <input |
| 101 | + type="text" |
| 102 | + value={query} |
| 103 | + onChange={(e) => handleSearch(e.target.value)} |
| 104 | + placeholder="Change Location..." |
| 105 | + className="w-full bg-[#14181F] border border-gray-700/50 rounded-lg py-2 pl-10 pr-3 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500/50 transition-all placeholder:text-gray-600" |
| 106 | + /> |
| 107 | + {searching && ( |
| 108 | + <div className="absolute right-3 animate-spin h-3 w-3 border-2 border-blue-500 border-t-transparent rounded-full" /> |
| 109 | + )} |
| 110 | + </div> |
| 111 | + |
| 112 | + <AnimatePresence> |
| 113 | + {results.length > 0 && ( |
| 114 | + <motion.ul |
| 115 | + initial={{ opacity: 0, y: 5 }} |
| 116 | + animate={{ opacity: 1, y: 0 }} |
| 117 | + exit={{ opacity: 0, y: 5 }} |
| 118 | + className="absolute z-20 w-full mt-2 bg-[#0D1016] border border-gray-800 rounded-xl overflow-hidden shadow-2xl" |
| 119 | + > |
| 120 | + {results.map((loc) => ( |
| 121 | + <li |
| 122 | + key={loc.id} |
| 123 | + onClick={() => selectLocation(loc)} |
| 124 | + className="px-4 py-3 hover:bg-[#14181F] cursor-pointer transition-colors border-b border-gray-800 last:border-0" |
| 125 | + > |
| 126 | + <div className="flex flex-col"> |
| 127 | + <span className="text-sm font-medium">{loc.name}</span> |
| 128 | + <span className="text-[10px] text-gray-500 uppercase tracking-wider">{loc.country} {loc.admin1 && `• ${loc.admin1}`}</span> |
| 129 | + </div> |
| 130 | + </li> |
| 131 | + ))} |
| 132 | + </motion.ul> |
| 133 | + )} |
| 134 | + </AnimatePresence> |
| 135 | + </div> |
| 136 | + </div> |
| 137 | + </header> |
| 138 | + |
| 139 | + {/* Main Content Area */} |
| 140 | + <main className="flex-1 grid grid-cols-12 gap-0 overflow-hidden relative"> |
| 141 | + {loading ? ( |
| 142 | + <div className="col-span-12 flex flex-col items-center justify-center space-y-4"> |
| 143 | + <div className="w-10 h-10 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" /> |
| 144 | + <p className="text-[10px] uppercase font-bold tracking-[0.3em] text-gray-500">Synthesizing Atmospheric Models</p> |
| 145 | + </div> |
| 146 | + ) : weather && selectedLocation ? ( |
| 147 | + <> |
| 148 | + {/* Left Panel: Analysis */} |
| 149 | + <div className="col-span-12 lg:col-span-8 p-6 md:p-10 flex flex-col justify-between overflow-y-auto lg:overflow-hidden bg-[#0A0C10]"> |
| 150 | + <motion.div |
| 151 | + initial={{ opacity: 0, y: 20 }} |
| 152 | + animate={{ opacity: 1, y: 0 }} |
| 153 | + > |
| 154 | + <div className="inline-block px-3 py-1 bg-blue-500/10 border border-blue-500/20 rounded-full text-blue-400 text-[10px] font-bold uppercase tracking-widest mb-6"> |
| 155 | + Agent Perspective • Real-time Synthesis |
| 156 | + </div> |
| 157 | + |
| 158 | + <h1 className="text-[60px] md:text-[80px] leading-[0.9] font-light text-white tracking-tighter mb-8"> |
| 159 | + Atmospheric <br/> <span className="font-medium text-blue-500 italic uppercase">Stability.</span> |
| 160 | + </h1> |
| 161 | + |
| 162 | + {insights && ( |
| 163 | + <motion.div |
| 164 | + initial={{ opacity: 0 }} |
| 165 | + animate={{ opacity: 1 }} |
| 166 | + transition={{ delay: 0.3 }} |
| 167 | + className="max-w-xl text-lg md:text-xl text-gray-400 leading-relaxed font-light" |
| 168 | + > |
| 169 | + "{insights.summary}" |
| 170 | + </motion.div> |
| 171 | + )} |
| 172 | + </motion.div> |
| 173 | + |
| 174 | + {/* Key Stats Strip */} |
| 175 | + <motion.div |
| 176 | + initial={{ opacity: 0, y: 20 }} |
| 177 | + animate={{ opacity: 1, y: 0 }} |
| 178 | + transition={{ delay: 0.4 }} |
| 179 | + className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8 md:mt-0" |
| 180 | + > |
| 181 | + <div className="bg-[#14181F] p-5 rounded-2xl border border-gray-800/50 hover:border-blue-500/30 transition-colors group"> |
| 182 | + <div className="flex items-center gap-2 mb-2"> |
| 183 | + <Thermometer className="w-3 h-3 text-gray-500 group-hover:text-blue-400 transition-colors" /> |
| 184 | + <p className="text-gray-500 text-[10px] uppercase tracking-widest">Temperature</p> |
| 185 | + </div> |
| 186 | + <p className="text-3xl font-semibold text-white">{weather.current.temp}°C</p> |
| 187 | + <p className="text-blue-400 text-[10px] mt-1 font-medium">{weather.current.description}</p> |
| 188 | + </div> |
| 189 | + |
| 190 | + <div className="bg-[#14181F] p-5 rounded-2xl border border-gray-800/50 hover:border-blue-500/30 transition-colors group"> |
| 191 | + <div className="flex items-center gap-2 mb-2"> |
| 192 | + <Wind className="w-3 h-3 text-gray-500 group-hover:text-blue-400 transition-colors" /> |
| 193 | + <p className="text-gray-500 text-[10px] uppercase tracking-widest">Wind Flow</p> |
| 194 | + </div> |
| 195 | + <p className="text-3xl font-semibold text-white">{weather.current.windSpeed} <span className="text-xs font-normal text-gray-500 uppercase">km/h</span></p> |
| 196 | + <p className="text-gray-400 text-[10px] mt-1">Status: Stable</p> |
| 197 | + </div> |
| 198 | + |
| 199 | + <div className="bg-[#14181F] p-5 rounded-2xl border border-gray-800/50 hover:border-blue-500/30 transition-colors group"> |
| 200 | + <div className="flex items-center gap-2 mb-2"> |
| 201 | + <Droplets className="w-3 h-3 text-gray-500 group-hover:text-blue-400 transition-colors" /> |
| 202 | + <p className="text-gray-500 text-[10px] uppercase tracking-widest">Saturation</p> |
| 203 | + </div> |
| 204 | + <p className="text-3xl font-semibold text-white">{weather.current.humidity}%</p> |
| 205 | + <p className="text-gray-400 text-[10px] mt-1">Relative Humidity</p> |
| 206 | + </div> |
| 207 | + |
| 208 | + <div className="bg-[#14181F] p-5 rounded-2xl border border-gray-800/50 hover:border-blue-500/30 transition-colors group"> |
| 209 | + <div className="flex items-center gap-2 mb-2"> |
| 210 | + <Clock className="w-3 h-3 text-gray-500 group-hover:text-blue-400 transition-colors" /> |
| 211 | + <p className="text-gray-500 text-[10px] uppercase tracking-widest">Forecast</p> |
| 212 | + </div> |
| 213 | + <p className="text-3xl font-semibold text-white">4%</p> |
| 214 | + <p className="text-gray-400 text-[10px] mt-1">Precipitation</p> |
| 215 | + </div> |
| 216 | + </motion.div> |
| 217 | + </div> |
| 218 | + |
| 219 | + {/* Right Panel: Detailed Forecast & Recommendations */} |
| 220 | + <div className="col-span-12 lg:col-span-4 bg-[#0D1016] border-l border-gray-800 p-8 flex flex-col overflow-y-auto"> |
| 221 | + <h2 className="text-[10px] font-bold uppercase tracking-[0.2em] text-gray-500 mb-8 flex items-center gap-2"> |
| 222 | + <Navigation className="w-3 h-3" /> 72-Hour Outlook |
| 223 | + </h2> |
| 224 | + |
| 225 | + <div className="space-y-3 mb-10"> |
| 226 | + {weather.daily.map((day, idx) => ( |
| 227 | + <motion.div |
| 228 | + initial={{ opacity: 0, x: 20 }} |
| 229 | + animate={{ opacity: 1, x: 0 }} |
| 230 | + transition={{ delay: 0.5 + (idx * 0.1) }} |
| 231 | + key={day.date} |
| 232 | + className={`flex items-center justify-between p-4 rounded-xl border transition-all group ${idx === 0 ? 'bg-[#14181F] border-blue-500/20' : 'bg-transparent border-transparent hover:bg-[#14181F]'}`} |
| 233 | + > |
| 234 | + <span className={`w-14 text-sm font-medium ${idx === 0 ? 'text-white' : 'text-gray-400'}`}> |
| 235 | + {idx === 0 ? 'Today' : format(new Date(day.date), 'EEE')} |
| 236 | + </span> |
| 237 | + <div className="flex-1 flex justify-center"> |
| 238 | + <WeatherIcon name={day.icon} className={`w-5 h-5 ${idx === 0 ? 'text-blue-400 drop-shadow-[0_0_8px_rgba(59,130,246,0.3)]' : 'text-gray-500'}`} /> |
| 239 | + </div> |
| 240 | + <span className={`text-sm font-bold ${idx === 0 ? 'text-white' : 'text-gray-400'}`}> |
| 241 | + {day.maxTemp}° / {day.minTemp}° |
| 242 | + </span> |
| 243 | + </motion.div> |
| 244 | + ))} |
| 245 | + </div> |
| 246 | + |
| 247 | + {insights && ( |
| 248 | + <div className="mt-auto space-y-4"> |
| 249 | + <div className="p-5 bg-[#14181F] rounded-2xl border border-gray-800/50"> |
| 250 | + <p className="text-[10px] font-bold uppercase tracking-widest text-gray-500 mb-3">Agent Directives</p> |
| 251 | + <div className="space-y-3"> |
| 252 | + <div> |
| 253 | + <p className="text-[9px] text-gray-600 uppercase mb-1">Recommended Activity</p> |
| 254 | + <p className="text-sm text-gray-300 font-light">{insights.activities.recommended[0]}</p> |
| 255 | + </div> |
| 256 | + <div> |
| 257 | + <p className="text-[9px] text-gray-600 uppercase mb-1">Recommended Attire</p> |
| 258 | + <p className="text-sm text-gray-300 font-light">{insights.clothing.slice(0, 2).join(', ')}</p> |
| 259 | + </div> |
| 260 | + </div> |
| 261 | + </div> |
| 262 | + |
| 263 | + <div className="p-6 bg-gradient-to-br from-blue-600/10 to-purple-600/10 rounded-2xl border border-white/5 relative overflow-hidden group"> |
| 264 | + <div className="absolute top-0 right-0 p-2 opacity-10"> |
| 265 | + <Sparkles className="w-8 h-8 text-blue-400" /> |
| 266 | + </div> |
| 267 | + <p className="text-[10px] font-bold uppercase tracking-wider text-blue-400 mb-2">Smart Recommendation</p> |
| 268 | + <p className="text-xs leading-relaxed text-gray-400 font-light italic"> |
| 269 | + {insights.funFact} |
| 270 | + </p> |
| 271 | + </div> |
| 272 | + </div> |
| 273 | + )} |
| 274 | + </div> |
| 275 | + </> |
| 276 | + ) : ( |
| 277 | + <div className="col-span-12 flex flex-col items-center justify-center p-12 text-center"> |
| 278 | + <motion.div |
| 279 | + initial={{ scale: 0.9, opacity: 0 }} |
| 280 | + animate={{ scale: 1, opacity: 1 }} |
| 281 | + className="max-w-md" |
| 282 | + > |
| 283 | + <div className="w-16 h-16 bg-blue-500/10 border border-blue-500/20 rounded-full flex items-center justify-center mx-auto mb-8"> |
| 284 | + <Sparkles className="w-8 h-8 text-blue-500/40" /> |
| 285 | + </div> |
| 286 | + <h2 className="text-3xl font-light tracking-tight text-white mb-4">Initialize SkyCast Proxy</h2> |
| 287 | + <p className="text-gray-500 text-sm leading-relaxed mb-8"> |
| 288 | + Enter a geographical coordinate or allow sensor mesh access to synthesize real-time atmospheric data models for your location. |
| 289 | + </p> |
| 290 | + </motion.div> |
| 291 | + </div> |
| 292 | + )} |
| 293 | + </main> |
| 294 | + |
| 295 | + {/* Footer Status Bar */} |
| 296 | + <footer className="px-8 py-4 bg-[#080A0E] border-t border-gray-900 shrink-0 flex justify-between items-center text-[9px] text-gray-600 uppercase tracking-[0.2em] font-medium"> |
| 297 | + <div className="hidden md:flex gap-6"> |
| 298 | + <span>Sensor Mesh: <span className="text-green-500/70">Active</span></span> |
| 299 | + <span>Latency: 42ms</span> |
| 300 | + <span>Atmospheric Model: GFS-1.0</span> |
| 301 | + </div> |
| 302 | + <div className="flex items-center gap-2"> |
| 303 | + <div className="w-1 h-1 bg-green-500 rounded-full animate-pulse"></div> |
| 304 | + <span>AI Engine Fully Synchronized</span> |
| 305 | + </div> |
| 306 | + </footer> |
| 307 | + </div> |
| 308 | + ); |
| 309 | +} |
0 commit comments