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:
Sam 2026-03-03 01:13:49 +01:00
parent d9626108e6
commit 2f56be835e
13 changed files with 379 additions and 36 deletions

View file

@ -37,6 +37,7 @@ class Settings:
# --- Weather ---
weather_location: str = "Leverkusen"
weather_location_secondary: str = "Rab,Croatia"
weather_location_tertiary: str = "München"
weather_cache_ttl: int = 1800
# --- Home Assistant ---
@ -94,6 +95,7 @@ class Settings:
# Legacy ENV support — used for first-run seeding
s.weather_location = os.getenv("WEATHER_LOCATION", s.weather_location)
s.weather_location_secondary = os.getenv("WEATHER_LOCATION_SECONDARY", s.weather_location_secondary)
s.weather_location_tertiary = os.getenv("WEATHER_LOCATION_TERTIARY", s.weather_location_tertiary)
s.ha_url = os.getenv("HA_URL", s.ha_url)
s.ha_token = os.getenv("HA_TOKEN", s.ha_token)
s.ha_enabled = bool(s.ha_url)
@ -151,6 +153,7 @@ class Settings:
if itype == "weather":
self.weather_location = cfg.get("location", self.weather_location)
self.weather_location_secondary = cfg.get("location_secondary", self.weather_location_secondary)
self.weather_location_tertiary = cfg.get("location_tertiary", self.weather_location_tertiary)
elif itype == "news":
self.news_max_age_hours = int(cfg.get("max_age_hours", self.news_max_age_hours))

View file

@ -16,6 +16,9 @@ CREATE INDEX IF NOT EXISTS idx_market_news_published
CREATE INDEX IF NOT EXISTS idx_market_news_category
ON market_news (category);
CREATE UNIQUE INDEX IF NOT EXISTS idx_market_news_url_unique
ON market_news (url);
-- Record this migration
INSERT INTO schema_version (version, description)
VALUES (2, 'market_news table for n8n-sourced articles')

View file

@ -1,4 +1,4 @@
"""Weather data router -- primary + secondary locations and hourly forecast."""
"""Weather data router -- primary, secondary & tertiary locations with hourly forecasts."""
from __future__ import annotations
@ -31,29 +31,41 @@ async def get_weather() -> Dict[str, Any]:
# --- cache miss -- fetch all three in parallel ----------------------------
cfg = get_settings()
logger.info("[WEATHER] Cache miss — fetching '%s' + '%s'",
cfg.weather_location, cfg.weather_location_secondary)
logger.info("[WEATHER] Cache miss — fetching '%s' + '%s' + '%s'",
cfg.weather_location, cfg.weather_location_secondary,
cfg.weather_location_tertiary)
results = await asyncio.gather(
_safe_fetch_weather(cfg.weather_location),
_safe_fetch_weather(cfg.weather_location_secondary),
_safe_fetch_hourly(cfg.weather_location),
_safe_fetch_weather(cfg.weather_location_tertiary),
_safe_fetch_hourly(cfg.weather_location, max_slots=24),
_safe_fetch_hourly(cfg.weather_location_secondary, max_slots=24),
_safe_fetch_hourly(cfg.weather_location_tertiary, max_slots=24),
return_exceptions=False,
)
primary_data = results[0]
secondary_data = results[1]
hourly_data = results[2]
tertiary_data = results[2]
hourly_data = results[3]
hourly_secondary = results[4]
hourly_tertiary = results[5]
# Log result summary
_log_weather_result("primary", cfg.weather_location, primary_data)
_log_weather_result("secondary", cfg.weather_location_secondary, secondary_data)
logger.info("[WEATHER] Hourly: %d slots", len(hourly_data))
_log_weather_result("tertiary", cfg.weather_location_tertiary, tertiary_data)
logger.info("[WEATHER] Hourly: %d + %d + %d slots",
len(hourly_data), len(hourly_secondary), len(hourly_tertiary))
payload: Dict[str, Any] = {
"primary": primary_data,
"secondary": secondary_data,
"tertiary": tertiary_data,
"hourly": hourly_data,
"hourly_secondary": hourly_secondary,
"hourly_tertiary": hourly_tertiary,
}
await cache.set(CACHE_KEY, payload, cfg.weather_cache_ttl)
@ -83,10 +95,10 @@ async def _safe_fetch_weather(location: str) -> Dict[str, Any]:
return {"error": True, "message": str(exc), "location": location}
async def _safe_fetch_hourly(location: str) -> List[Dict[str, Any]]:
async def _safe_fetch_hourly(location: str, max_slots: int = 8) -> List[Dict[str, Any]]:
"""Fetch hourly forecast for *location*, returning ``[]`` on failure."""
try:
return await fetch_hourly_forecast(location)
return await fetch_hourly_forecast(location, max_slots=max_slots)
except Exception as exc:
logger.exception("[WEATHER] Unhandled error fetching hourly for '%s'", location)
return []

View file

@ -44,6 +44,7 @@ async def seed_if_empty() -> None:
"config": {
"location": os.getenv("WEATHER_LOCATION", "Leverkusen"),
"location_secondary": os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia"),
"location_tertiary": os.getenv("WEATHER_LOCATION_TERTIARY", "München"),
},
"enabled": True,
"display_order": 0,

View file

@ -213,7 +213,7 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
"wind_kmh": 0,
"description": "Nicht verfügbar",
"icon": "",
"forecast_3day": [],
"forecast": [],
"error": None,
}
@ -235,7 +235,7 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m",
"daily": "weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset",
"timezone": geo["timezone"],
"forecast_days": 3,
"forecast_days": 7,
},
)
resp.raise_for_status()
@ -266,36 +266,42 @@ async def fetch_weather(location: str) -> Dict[str, Any]:
"error": None,
}
# Step 4: Parse 3-day forecast
# Step 4: Parse 7-day forecast
daily = data.get("daily", {})
dates = daily.get("time", [])
forecast_3day: List[Dict[str, Any]] = []
forecast: List[Dict[str, Any]] = []
for i, date_str in enumerate(dates[:3]):
code = int(daily.get("weather_code", [0] * 3)[i])
forecast_3day.append({
for i, date_str in enumerate(dates):
codes_list = daily.get("weather_code", [])
code = int(codes_list[i]) if i < len(codes_list) else 0
max_temps = daily.get("temperature_2m_max", [])
min_temps = daily.get("temperature_2m_min", [])
sunrises = daily.get("sunrise", [])
sunsets = daily.get("sunset", [])
forecast.append({
"date": date_str,
"max_temp": round(daily.get("temperature_2m_max", [0] * 3)[i]),
"min_temp": round(daily.get("temperature_2m_min", [0] * 3)[i]),
"max_temp": round(max_temps[i]) if i < len(max_temps) else 0,
"min_temp": round(min_temps[i]) if i < len(min_temps) else 0,
"icon": _wmo_icon(code),
"description": _wmo_description(code),
"sunrise": daily.get("sunrise", [""] * 3)[i].split("T")[-1] if daily.get("sunrise") else "",
"sunset": daily.get("sunset", [""] * 3)[i].split("T")[-1] if daily.get("sunset") else "",
"sunrise": sunrises[i].split("T")[-1] if i < len(sunrises) and sunrises[i] else "",
"sunset": sunsets[i].split("T")[-1] if i < len(sunsets) and sunsets[i] else "",
})
result["forecast_3day"] = forecast_3day
result["forecast"] = forecast
logger.info("[WEATHER] Fetched '%s': %d°C, %s", geo["name"], result["temp"], result["description"])
return result
async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
"""Fetch hourly forecast for the next 8 hours from Open-Meteo.
async def fetch_hourly_forecast(location: str, max_slots: int = 8) -> List[Dict[str, Any]]:
"""Fetch hourly forecast from Open-Meteo.
Args:
location: City name or coordinates.
max_slots: Maximum number of hourly slots to return.
Returns:
List of hourly forecast dicts (max 8 entries).
List of hourly forecast dicts.
"""
geo = await _geocode(location)
if geo is None:
@ -354,7 +360,7 @@ async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
"wind_kmh": round(winds[i]) if i < len(winds) else 0,
})
if len(upcoming) >= 8:
if len(upcoming) >= max_slots:
break
logger.debug("[WEATHER] Hourly for '%s': %d slots", location, len(upcoming))

View file

@ -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>

View file

@ -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 {

View file

@ -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>

View 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>
);
}

View file

@ -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: [

View file

@ -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>

View file

@ -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 {

View file

@ -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)" },
},
},
},
},