Add weather to header, collapsible sections, staggered card animation
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-16 16:13:10 +02:00
parent 6389e323dd
commit 7bbefaa1f6
6 changed files with 212 additions and 7 deletions
+2
View File
@@ -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') {
+37
View File
@@ -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
+22 -3
View File
@@ -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<Set<string>>(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 }) => (
{dashboardSections.map(({ label, widgets }) => {
const isCollapsed = collapsed.has(label)
return (
<div key={label}>
<div className="section-label">{label}</div>
<div className="section-label" onClick={() => toggle(label)}>
{label}
<span className={`section-chevron${isCollapsed ? ' collapsed' : ''}`}></span>
</div>
{!isCollapsed && (
<div className="infra-grid">
{widgets.map((Widget, i) => <Widget key={i} />)}
</div>
)}
</div>
))}
)
})}
</>
)
}
+36
View File
@@ -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 (
<header>
<div className="logo">
@@ -30,6 +57,15 @@ export function Header() {
<NavLink to="/" end className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Dashboard</NavLink>
<NavLink to="/apps" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Apps</NavLink>
</nav>
{weather && (
<div className="weather-inline">
<span className="weather-emoji">{weatherEmoji(weather.code)}</span>
<div>
<div className="weather-temp">{weather.tempC}°C</div>
<div className="weather-desc">{weather.desc}</div>
</div>
</div>
)}
<div className="clock-widget">
<div className="clock-time">{time}</div>
<div className="clock-date">{date}</div>
+84
View File
@@ -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<WeatherData | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="card">
<div className="widget-header">
<div className="widget-title"><span className="dot" />Weather</div>
</div>
<div className="widget-error"> {error}</div>
</div>
)
if (!data) return (
<div className="card">
<div className="widget-loading">Loading</div>
</div>
)
return (
<div className="card">
<div className="widget-header">
<div className="widget-title"><span className="dot" />Weather</div>
<span className="widget-badge">{data.city}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
<span style={{ fontSize: '40px', lineHeight: 1 }}>{weatherEmoji(data.code)}</span>
<div>
<div className="stat-value accent" style={{ fontSize: '32px' }}>{data.tempC}°C</div>
<div className="stat-label">{data.desc}</div>
</div>
</div>
<div className="stat-row">
<div className="stat-item">
<div className="stat-value" style={{ fontSize: '16px' }}>{data.feelsLikeC}°C</div>
<div className="stat-label">Feels like</div>
</div>
<div className="stat-item">
<div className="stat-value" style={{ fontSize: '16px' }}>{data.humidity}%</div>
<div className="stat-label">Humidity</div>
</div>
<div className="stat-item">
<div className="stat-value" style={{ fontSize: '16px' }}>{data.windKmph} km/h</div>
<div className="stat-label">Wind</div>
</div>
</div>
</div>
)
}
+27
View File
@@ -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;