From 7bbefaa1f69ccf7e7ec1e423badddfb27185c67b Mon Sep 17 00:00:00 2001 From: Syco21 Date: Sat, 16 May 2026 16:13:10 +0200 Subject: [PATCH] Add weather to header, collapsible sections, staggered card animation --- server/index.ts | 2 + server/routes/weather.ts | 37 +++++++++++ src/App.tsx | 33 ++++++++-- src/components/Header.tsx | 36 ++++++++++ src/components/widgets/WeatherWidget.tsx | 84 ++++++++++++++++++++++++ src/index.css | 27 ++++++++ 6 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 server/routes/weather.ts create mode 100644 src/components/widgets/WeatherWidget.tsx diff --git a/server/index.ts b/server/index.ts index a248803..98b2c9d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,6 +16,7 @@ import qbt from './routes/qbittorrent' import jellyfin from './routes/jellyfin' import navidrome from './routes/navidrome' import romm from './routes/romm' +import weather from './routes/weather' const app = express() const PORT = Number(process.env.PORT ?? 3001) @@ -37,6 +38,7 @@ app.use('/api/qbt', qbt) app.use('/api/jellyfin', jellyfin) app.use('/api/navidrome', navidrome) app.use('/api/romm', romm) +app.use('/api/weather', weather) // Serve built frontend in production only if (process.env.NODE_ENV === 'production') { diff --git a/server/routes/weather.ts b/server/routes/weather.ts new file mode 100644 index 0000000..0ad1070 --- /dev/null +++ b/server/routes/weather.ts @@ -0,0 +1,37 @@ +import { Router } from 'express' +import axios from 'axios' + +const router = Router() + +router.get('/current', async (_req, res) => { + try { + const location = process.env.WEATHER_LOCATION + if (!location) { + res.status(503).json({ error: 'WEATHER_LOCATION not configured' }) + return + } + + const response = await axios.get( + `https://wttr.in/${encodeURIComponent(location)}?format=j1`, + { headers: { 'Accept': 'application/json' }, timeout: 8000 } + ) + + const c = response.data.current_condition[0] + const area = response.data.nearest_area?.[0] + + res.json({ + tempC: Number(c.temp_C), + feelsLikeC: Number(c.FeelsLikeC), + humidity: Number(c.humidity), + windKmph: Number(c.windspeedKmph), + desc: c.weatherDesc[0].value as string, + code: Number(c.weatherCode), + city: area?.areaName?.[0]?.value ?? location, + }) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Unknown error' + res.status(500).json({ error: msg }) + } +}) + +export default router diff --git a/src/App.tsx b/src/App.tsx index ce61bc6..fb07e40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,19 +1,38 @@ +import { useState } from 'react' import { BrowserRouter, Routes, Route } from 'react-router-dom' import { Header } from './components/Header' import { AppsPage } from './pages/AppsPage' import { dashboardSections } from './config/dashboard' function DashboardPage() { + const [collapsed, setCollapsed] = useState>(new Set()) + + const toggle = (label: string) => { + setCollapsed(prev => { + const next = new Set(prev) + next.has(label) ? next.delete(label) : next.add(label) + return next + }) + } + return ( <> - {dashboardSections.map(({ label, widgets }) => ( -
-
{label}
-
- {widgets.map((Widget, i) => )} + {dashboardSections.map(({ label, widgets }) => { + const isCollapsed = collapsed.has(label) + return ( +
+
toggle(label)}> + {label} + +
+ {!isCollapsed && ( +
+ {widgets.map((Widget, i) => )} +
+ )}
-
- ))} + ) + })} ) } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 95b991c..b92ab60 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,9 +1,21 @@ import { useState, useEffect } from 'react' import { NavLink } from 'react-router-dom' +function weatherEmoji(code: number): string { + if (code === 113) return '☀️' + if (code === 116) return '⛅' + if (code === 119 || code === 122) return '☁️' + if ([143, 248, 260].includes(code)) return '🌫️' + if ([176, 263, 266, 281, 284, 293, 296, 299, 302, 305, 308, 353, 356, 359].includes(code)) return '🌧️' + if ([179, 182, 185, 227, 230, 317, 320, 323, 326, 329, 332, 335, 338, 350, 362, 365, 368, 371, 374, 377].includes(code)) return '❄️' + if ([200, 386, 389, 392, 395].includes(code)) return '⛈️' + return '🌡️' +} + export function Header() { const [time, setTime] = useState('') const [date, setDate] = useState('') + const [weather, setWeather] = useState<{ tempC: number; code: number; desc: string } | null>(null) useEffect(() => { const tick = () => { @@ -16,6 +28,21 @@ export function Header() { return () => clearInterval(id) }, []) + useEffect(() => { + fetch('/api/weather/current') + .then(r => r.json()) + .then(d => { if (!d.error) setWeather(d) }) + .catch(() => {}) + + const id = setInterval(() => { + fetch('/api/weather/current') + .then(r => r.json()) + .then(d => { if (!d.error) setWeather(d) }) + .catch(() => {}) + }, 10 * 60 * 1000) // refresh every 10 min + return () => clearInterval(id) + }, []) + return (
@@ -30,6 +57,15 @@ export function Header() { `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Dashboard `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Apps + {weather && ( +
+ {weatherEmoji(weather.code)} +
+
{weather.tempC}°C
+
{weather.desc}
+
+
+ )}
{time}
{date}
diff --git a/src/components/widgets/WeatherWidget.tsx b/src/components/widgets/WeatherWidget.tsx new file mode 100644 index 0000000..4ad1dad --- /dev/null +++ b/src/components/widgets/WeatherWidget.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' + +interface WeatherData { + tempC: number + feelsLikeC: number + humidity: number + windKmph: number + desc: string + code: number + city: string +} + +function weatherEmoji(code: number): string { + if (code === 113) return '☀️' + if (code === 116) return '⛅' + if (code === 119 || code === 122) return '☁️' + if ([143, 248, 260].includes(code)) return '🌫️' + if ([176, 263, 266, 281, 284, 293, 296, 299, 302, 305, 308, 353, 356, 359].includes(code)) return '🌧️' + if ([179, 182, 185, 227, 230, 317, 320, 323, 326, 329, 332, 335, 338, 350, 362, 365, 368, 371, 374, 377].includes(code)) return '❄️' + if ([200, 386, 389, 392, 395].includes(code)) return '⛈️' + return '🌡️' +} + +export function WeatherWidget() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + fetch('/api/weather/current') + .then(r => r.json()) + .then(d => { + if (d.error) setError(d.error) + else setData(d) + }) + .catch(() => setError('Failed to fetch weather')) + }, []) + + if (error) return ( +
+
+
Weather
+
+
⚠ {error}
+
+ ) + + if (!data) return ( +
+
Loading…
+
+ ) + + return ( +
+
+
Weather
+ {data.city} +
+ +
+ {weatherEmoji(data.code)} +
+
{data.tempC}°C
+
{data.desc}
+
+
+ +
+
+
{data.feelsLikeC}°C
+
Feels like
+
+
+
{data.humidity}%
+
Humidity
+
+
+
{data.windKmph} km/h
+
Wind
+
+
+
+ ) +} diff --git a/src/index.css b/src/index.css index c5194c1..53e4096 100644 --- a/src/index.css +++ b/src/index.css @@ -73,6 +73,11 @@ header { .clock-time { font-family: 'JetBrains Mono', monospace; font-size: 22px; font-weight: 500; letter-spacing: 1px; } .clock-date { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); margin-top: 2px; } +.weather-inline { display: flex; align-items: center; gap: 8px; } +.weather-emoji { font-size: 22px; line-height: 1; } +.weather-temp { font-family: 'JetBrains Mono', monospace; font-size: 22px; font-weight: 500; letter-spacing: 1px; } +.weather-desc { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); margin-top: 2px; } + /* ── Section label ── */ .section-label { font-family: 'JetBrains Mono', monospace; @@ -81,8 +86,18 @@ header { color: var(--muted); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; + cursor: pointer; + user-select: none; } +.section-label:hover { color: var(--text2); } .section-label::after { content: ''; flex: 1; height: 1px; background: var(--border); } +.section-chevron { + font-size: 12px; + transition: transform 0.2s ease; + margin-left: -4px; + letter-spacing: 0; +} +.section-chevron.collapsed { transform: rotate(-90deg); } /* ── Grid ── */ .infra-grid { @@ -115,6 +130,18 @@ header { } .card { animation: fadeUp 0.4s ease both; } +/* ── Staggered card entrance ── */ +.infra-grid > *:nth-child(1) { animation-delay: 0ms; } +.infra-grid > *:nth-child(2) { animation-delay: 60ms; } +.infra-grid > *:nth-child(3) { animation-delay: 120ms; } +.infra-grid > *:nth-child(4) { animation-delay: 180ms; } +.infra-grid > *:nth-child(5) { animation-delay: 240ms; } +.infra-grid > *:nth-child(6) { animation-delay: 300ms; } +.infra-grid > *:nth-child(7) { animation-delay: 360ms; } +.infra-grid > *:nth-child(8) { animation-delay: 420ms; } +.infra-grid > *:nth-child(9) { animation-delay: 480ms; } +.infra-grid > *:nth-child(10) { animation-delay: 540ms; } + /* ── Widget header ── */ .widget-header { display: flex; align-items: center; justify-content: space-between;