Add: München als 3. Wetter-Location + Wetter-Detail-Modal
- München als tertiärer Standort (iris-Akzent) hinzugefügt - Klick auf WeatherCard öffnet Detail-Modal mit: - 24h stündliche Prognose (horizontal scrollbar) - 7-Tage-Vorhersage mit Temperaturbalken - Wind, Feuchte, Sonnenauf/-untergang - Backend: 7-Tage statt 3-Tage Forecast, 24 Hourly-Slots pro Standort - Backend: forecast_3day → forecast Feldname-Konsistenz - Dashboard: 3-Spalten Wetter-Grid statt 4 (HourlyForecast → Modal) - Admin: Tertiärer Standort konfigurierbar - THERMAL Design: iris glow, modal animation, Portal-basiertes Modal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d9626108e6
commit
2f56be835e
13 changed files with 379 additions and 36 deletions
|
|
@ -68,6 +68,14 @@ export default function WeatherSettings() {
|
|||
<TextInput
|
||||
value={(config.secondary_location as string) || ""}
|
||||
onChange={(v) => setConfig("secondary_location", v)}
|
||||
placeholder="z.B. Rab,Croatia oder 44.76,14.76"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Tertiärer Standort" description="Dritter Standort (z.B. München)">
|
||||
<TextInput
|
||||
value={(config.tertiary_location as string) || ""}
|
||||
onChange={(v) => setConfig("tertiary_location", v)}
|
||||
placeholder="z.B. München oder 48.137,11.576"
|
||||
/>
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ export interface HourlySlot {
|
|||
export interface WeatherResponse {
|
||||
primary: WeatherData;
|
||||
secondary: WeatherData;
|
||||
tertiary: WeatherData;
|
||||
hourly: HourlySlot[];
|
||||
hourly_secondary: HourlySlot[];
|
||||
hourly_tertiary: HourlySlot[];
|
||||
}
|
||||
|
||||
export interface NewsArticle {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@ import type { WeatherData } from "../api";
|
|||
|
||||
interface WeatherCardProps {
|
||||
data: WeatherData;
|
||||
accent: "cyan" | "amber";
|
||||
accent: "cyan" | "amber" | "iris";
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const ACCENT = {
|
||||
cyan: { strip: "gold", text: "text-gold", glow: "data-glow-gold", tag: "border-gold/30 text-gold bg-gold/5" },
|
||||
amber: { strip: "mint", text: "text-mint", glow: "data-glow-mint", tag: "border-mint/30 text-mint bg-mint/5" },
|
||||
iris: { strip: "iris", text: "text-iris", glow: "data-glow-iris", tag: "border-iris/30 text-iris bg-iris/5" },
|
||||
} as const;
|
||||
|
||||
export default function WeatherCard({ data, accent }: WeatherCardProps) {
|
||||
export default function WeatherCard({ data, accent, onClick }: WeatherCardProps) {
|
||||
const a = ACCENT[accent];
|
||||
|
||||
if (data.error) {
|
||||
|
|
@ -29,7 +31,14 @@ export default function WeatherCard({ data, accent }: WeatherCardProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="deck-card corner-marks p-6 animate-fade-in" data-accent={a.strip}>
|
||||
<div
|
||||
className="deck-card corner-marks p-6 animate-fade-in cursor-pointer hover:border-base-400 transition-colors"
|
||||
data-accent={a.strip}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
|
||||
>
|
||||
{/* Location tag */}
|
||||
<div className="mb-5">
|
||||
<span className={`tag ${a.tag}`}>{data.location}</span>
|
||||
|
|
|
|||
223
web/src/components/WeatherDetailModal.tsx
Normal file
223
web/src/components/WeatherDetailModal.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Droplets, Wind, Sunrise, Sunset } from "lucide-react";
|
||||
import type { WeatherData, HourlySlot } from "../api";
|
||||
|
||||
interface WeatherDetailModalProps {
|
||||
weather: WeatherData;
|
||||
hourly: HourlySlot[];
|
||||
onClose: () => void;
|
||||
accentColor: "gold" | "mint" | "iris";
|
||||
}
|
||||
|
||||
const ACCENT_CLASSES = {
|
||||
gold: { text: "text-gold", glow: "data-glow-gold", bg: "bg-gold", strip: "gold", bar: "bg-gold/70" },
|
||||
mint: { text: "text-mint", glow: "data-glow-mint", bg: "bg-mint", strip: "mint", bar: "bg-mint/70" },
|
||||
iris: { text: "text-iris", glow: "data-glow-iris", bg: "bg-iris", strip: "iris", bar: "bg-iris/70" },
|
||||
} as const;
|
||||
|
||||
const DAY_NAMES = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
|
||||
export default function WeatherDetailModal({ weather, hourly, onClose, accentColor }: WeatherDetailModalProps) {
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const a = ACCENT_CLASSES[accentColor];
|
||||
|
||||
// Focus close button on mount, ESC to close
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus();
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Prevent body scroll while modal is open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
|
||||
// Calculate global min/max for temperature range bars
|
||||
const forecast = weather.forecast || [];
|
||||
const allTemps = forecast.flatMap((d) => [d.min_temp, d.max_temp]);
|
||||
const globalMin = allTemps.length ? Math.min(...allTemps) : 0;
|
||||
const globalMax = allTemps.length ? Math.max(...allTemps) : 20;
|
||||
const tempRange = globalMax - globalMin || 1;
|
||||
|
||||
// Today's sunrise/sunset from forecast
|
||||
const today = forecast[0];
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Wetter ${weather.location}`}
|
||||
>
|
||||
<div
|
||||
className="deck-card w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 animate-modal-in"
|
||||
data-accent={a.strip}
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-6 pb-4">
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-lg font-bold uppercase tracking-wide ${a.text}`}>
|
||||
{weather.location}
|
||||
</h2>
|
||||
<p className="text-sm text-base-600 mt-1 capitalize">{weather.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<span className={`text-4xl font-mono font-bold ${a.text} ${a.glow} tracking-tighter`}>
|
||||
{Math.round(weather.temp)}
|
||||
</span>
|
||||
<span className="text-lg font-light text-base-500">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-4xl select-none opacity-80">{weather.icon}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
ref={closeRef}
|
||||
onClick={onClose}
|
||||
className="ml-4 p-1.5 text-base-500 hover:text-base-900 transition-colors"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-4 gap-px bg-base-300 mx-6 mb-6">
|
||||
<MiniStat icon={<Wind className="w-3 h-3" />} label="Wind" value={`${Math.round(weather.wind_kmh)} km/h`} />
|
||||
<MiniStat icon={<Droplets className="w-3 h-3" />} label="Feuchte" value={`${weather.humidity}%`} />
|
||||
{today?.sunrise && (
|
||||
<MiniStat icon={<Sunrise className="w-3 h-3" />} label="Aufgang" value={today.sunrise} />
|
||||
)}
|
||||
{today?.sunset && (
|
||||
<MiniStat icon={<Sunset className="w-3 h-3" />} label="Untergang" value={today.sunset} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hourly forecast */}
|
||||
{hourly.length > 0 && (
|
||||
<div className="px-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="section-number">Stündlich</span>
|
||||
<span className="section-rule" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-0 overflow-x-auto border border-base-300"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{hourly.map((slot, i) => (
|
||||
<div
|
||||
key={slot.time}
|
||||
className={`
|
||||
flex flex-col items-center gap-1 min-w-[3.5rem] px-2 py-3
|
||||
border-r border-base-300 last:border-r-0
|
||||
${i === 0 ? `${a.bg}/[0.06]` : "hover:bg-base-100"}
|
||||
`}
|
||||
>
|
||||
<span className={`text-[9px] font-mono font-medium tracking-wider uppercase ${
|
||||
i === 0 ? a.text : "text-base-500"
|
||||
}`}>
|
||||
{i === 0 ? "Jetzt" : slot.time}
|
||||
</span>
|
||||
<span className="text-lg select-none">{slot.icon}</span>
|
||||
<span className={`text-xs data-value ${i === 0 ? `${a.text} ${a.glow}` : "text-base-900"}`}>
|
||||
{Math.round(slot.temp)}°
|
||||
</span>
|
||||
{slot.precip_chance > 0 && (
|
||||
<span className="text-[8px] font-mono text-azure/80">
|
||||
{slot.precip_chance}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-day forecast */}
|
||||
{forecast.length > 0 && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="section-number">7 Tage</span>
|
||||
<span className="section-rule" />
|
||||
</div>
|
||||
|
||||
<div className="border border-base-300">
|
||||
{forecast.map((day, i) => {
|
||||
const date = new Date(day.date);
|
||||
const dayName = DAY_NAMES[date.getDay()];
|
||||
const dateStr = `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
const barLeft = ((day.min_temp - globalMin) / tempRange) * 100;
|
||||
const barWidth = ((day.max_temp - day.min_temp) / tempRange) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-2.5
|
||||
border-b border-base-300 last:border-b-0
|
||||
${i === 0 ? `${a.bg}/[0.04]` : ""}
|
||||
`}
|
||||
>
|
||||
{/* Day + date */}
|
||||
<span className={`text-xs font-mono font-semibold w-6 ${i === 0 ? a.text : "text-base-700"}`}>
|
||||
{i === 0 ? "Heu" : dayName}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-base-500 w-12">{dateStr}</span>
|
||||
|
||||
{/* Icon */}
|
||||
<span className="text-base select-none w-6 text-center">{day.icon}</span>
|
||||
|
||||
{/* Min temp */}
|
||||
<span className="text-xs font-mono text-base-500 w-7 text-right">
|
||||
{day.min_temp}°
|
||||
</span>
|
||||
|
||||
{/* Temperature range bar */}
|
||||
<div className="flex-1 h-1.5 bg-base-200 relative mx-1">
|
||||
<div
|
||||
className={`absolute top-0 h-full ${a.bar} transition-all duration-500`}
|
||||
style={{
|
||||
left: `${barLeft}%`,
|
||||
width: `${Math.max(barWidth, 3)}%`,
|
||||
borderRadius: "1px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max temp */}
|
||||
<span className="text-xs font-mono text-base-900 w-7">
|
||||
{day.max_temp}°
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-2.5 bg-base-50">
|
||||
<span className="text-base-500">{icon}</span>
|
||||
<span className="text-xs data-value text-base-900">{value}</span>
|
||||
<span className="data-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,6 +32,20 @@ export const MOCK_DATA: DashboardData = {
|
|||
{ date: "2026-03-04", max_temp: 17, min_temp: 9, icon: "⛅", description: "Partly cloudy" },
|
||||
],
|
||||
},
|
||||
tertiary: {
|
||||
location: "München",
|
||||
temp: 5,
|
||||
feels_like: 2,
|
||||
humidity: 65,
|
||||
wind_kmh: 14,
|
||||
description: "Bewölkt",
|
||||
icon: "☁️",
|
||||
forecast: [
|
||||
{ date: "2026-03-02", max_temp: 7, min_temp: 0, icon: "☁️", description: "Bewölkt" },
|
||||
{ date: "2026-03-03", max_temp: 9, min_temp: 2, icon: "🌤️", description: "Überwiegend klar" },
|
||||
{ date: "2026-03-04", max_temp: 5, min_temp: -1, icon: "❄️", description: "Leichter Schneefall" },
|
||||
],
|
||||
},
|
||||
hourly: [
|
||||
{ time: "14:00", temp: 8, icon: "⛅", precip_chance: 10 },
|
||||
{ time: "15:00", temp: 9, icon: "🌤️", precip_chance: 5 },
|
||||
|
|
@ -42,6 +56,26 @@ export const MOCK_DATA: DashboardData = {
|
|||
{ time: "20:00", temp: 4, icon: "🌧️", precip_chance: 60 },
|
||||
{ time: "21:00", temp: 4, icon: "☁️", precip_chance: 35 },
|
||||
],
|
||||
hourly_secondary: [
|
||||
{ time: "14:00", temp: 16, icon: "☀️", precip_chance: 0 },
|
||||
{ time: "15:00", temp: 17, icon: "☀️", precip_chance: 0 },
|
||||
{ time: "16:00", temp: 16, icon: "🌤️", precip_chance: 5 },
|
||||
{ time: "17:00", temp: 15, icon: "🌤️", precip_chance: 5 },
|
||||
{ time: "18:00", temp: 14, icon: "⛅", precip_chance: 10 },
|
||||
{ time: "19:00", temp: 13, icon: "☁️", precip_chance: 15 },
|
||||
{ time: "20:00", temp: 12, icon: "☁️", precip_chance: 10 },
|
||||
{ time: "21:00", temp: 11, icon: "🌤️", precip_chance: 5 },
|
||||
],
|
||||
hourly_tertiary: [
|
||||
{ time: "14:00", temp: 5, icon: "☁️", precip_chance: 20 },
|
||||
{ time: "15:00", temp: 6, icon: "☁️", precip_chance: 25 },
|
||||
{ time: "16:00", temp: 5, icon: "🌧️", precip_chance: 40 },
|
||||
{ time: "17:00", temp: 4, icon: "🌧️", precip_chance: 50 },
|
||||
{ time: "18:00", temp: 3, icon: "☁️", precip_chance: 30 },
|
||||
{ time: "19:00", temp: 2, icon: "☁️", precip_chance: 20 },
|
||||
{ time: "20:00", temp: 1, icon: "☁️", precip_chance: 15 },
|
||||
{ time: "21:00", temp: 0, icon: "❄️", precip_chance: 35 },
|
||||
],
|
||||
},
|
||||
news: {
|
||||
articles: [
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import Clock from "../components/Clock";
|
||||
import WeatherCard from "../components/WeatherCard";
|
||||
import HourlyForecast from "../components/HourlyForecast";
|
||||
import WeatherDetailModal from "../components/WeatherDetailModal";
|
||||
import NewsGrid from "../components/NewsGrid";
|
||||
import ServerCard from "../components/ServerCard";
|
||||
import HomeAssistant from "../components/HomeAssistant";
|
||||
|
|
@ -10,8 +11,29 @@ import TasksCard from "../components/TasksCard";
|
|||
import MqttCard from "../components/MqttCard";
|
||||
import { RefreshCw, AlertTriangle, Settings } from "lucide-react";
|
||||
|
||||
type WeatherLocationKey = "primary" | "secondary" | "tertiary";
|
||||
|
||||
const WEATHER_ACCENTS: Record<WeatherLocationKey, "cyan" | "amber" | "iris"> = {
|
||||
primary: "cyan",
|
||||
secondary: "amber",
|
||||
tertiary: "iris",
|
||||
};
|
||||
|
||||
const WEATHER_MODAL_COLORS: Record<WeatherLocationKey, "gold" | "mint" | "iris"> = {
|
||||
primary: "gold",
|
||||
secondary: "mint",
|
||||
tertiary: "iris",
|
||||
};
|
||||
|
||||
const HOURLY_KEYS: Record<WeatherLocationKey, "hourly" | "hourly_secondary" | "hourly_tertiary"> = {
|
||||
primary: "hourly",
|
||||
secondary: "hourly_secondary",
|
||||
tertiary: "hourly_tertiary",
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data, loading, error, refresh } = useDashboard();
|
||||
const [weatherModal, setWeatherModal] = useState<WeatherLocationKey | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-base-800">
|
||||
|
|
@ -74,13 +96,26 @@ export default function Dashboard() {
|
|||
<span className="section-rule" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-px bg-base-300">
|
||||
<WeatherCard data={data.weather.primary} accent="cyan" />
|
||||
<WeatherCard data={data.weather.secondary} accent="amber" />
|
||||
<div className="md:col-span-2">
|
||||
<HourlyForecast slots={data.weather.hourly} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-px bg-base-300">
|
||||
{(["primary", "secondary", "tertiary"] as const).map((key) => (
|
||||
<WeatherCard
|
||||
key={key}
|
||||
data={data.weather[key]}
|
||||
accent={WEATHER_ACCENTS[key]}
|
||||
onClick={() => setWeatherModal(key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Weather detail modal */}
|
||||
{weatherModal && (
|
||||
<WeatherDetailModal
|
||||
weather={data.weather[weatherModal]}
|
||||
hourly={data.weather[HOURLY_KEYS[weatherModal]] || []}
|
||||
accentColor={WEATHER_MODAL_COLORS[weatherModal]}
|
||||
onClose={() => setWeatherModal(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section 02 — Infrastruktur */}
|
||||
|
|
@ -157,8 +192,8 @@ function LoadingSkeleton() {
|
|||
<div className="h-3 w-16 bg-base-200" />
|
||||
<span className="section-rule" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-px bg-base-300">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-px bg-base-300">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<SkeletonCard key={`w-${i}`} className="h-52" />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@
|
|||
.data-glow-gold { text-shadow: 0 0 20px #e8a44a33, 0 0 40px #e8a44a15; }
|
||||
.data-glow-mint { text-shadow: 0 0 20px #4ae8a833, 0 0 40px #4ae8a815; }
|
||||
.data-glow-cherry { text-shadow: 0 0 20px #e85a5a33, 0 0 40px #e85a5a15; }
|
||||
.data-glow-iris { text-shadow: 0 0 20px #a87aec33, 0 0 40px #a87aec15; }
|
||||
|
||||
/* ── Tags/Badges ─────────────────────────────────────────── */
|
||||
.tag {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export default {
|
|||
"slide-in": "slideIn 0.3s ease-out both",
|
||||
"scan": "scan 1.5s ease-in-out infinite",
|
||||
"glow-pulse": "glowPulse 2s ease-in-out infinite",
|
||||
"modal-in": "modalIn 0.25s ease-out both",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
|
|
@ -72,6 +73,10 @@ export default {
|
|||
"0%, 100%": { opacity: "0.5" },
|
||||
"50%": { opacity: "1" },
|
||||
},
|
||||
modalIn: {
|
||||
"0%": { opacity: "0", transform: "scale(0.95) translateY(8px)" },
|
||||
"100%": { opacity: "1", transform: "scale(1) translateY(0)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue