Add weather to header, collapsible sections, staggered card animation
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
+26
-7
@@ -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 }) => (
|
||||
<div key={label}>
|
||||
<div className="section-label">{label}</div>
|
||||
<div className="infra-grid">
|
||||
{widgets.map((Widget, i) => <Widget key={i} />)}
|
||||
{dashboardSections.map(({ label, widgets }) => {
|
||||
const isCollapsed = collapsed.has(label)
|
||||
return (
|
||||
<div key={label}>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user