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 jellyfin from './routes/jellyfin'
|
||||||
import navidrome from './routes/navidrome'
|
import navidrome from './routes/navidrome'
|
||||||
import romm from './routes/romm'
|
import romm from './routes/romm'
|
||||||
|
import weather from './routes/weather'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = Number(process.env.PORT ?? 3001)
|
const PORT = Number(process.env.PORT ?? 3001)
|
||||||
@@ -37,6 +38,7 @@ app.use('/api/qbt', qbt)
|
|||||||
app.use('/api/jellyfin', jellyfin)
|
app.use('/api/jellyfin', jellyfin)
|
||||||
app.use('/api/navidrome', navidrome)
|
app.use('/api/navidrome', navidrome)
|
||||||
app.use('/api/romm', romm)
|
app.use('/api/romm', romm)
|
||||||
|
app.use('/api/weather', weather)
|
||||||
|
|
||||||
// Serve built frontend in production only
|
// Serve built frontend in production only
|
||||||
if (process.env.NODE_ENV === 'production') {
|
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
|
||||||
+22
-3
@@ -1,19 +1,38 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
import { Header } from './components/Header'
|
import { Header } from './components/Header'
|
||||||
import { AppsPage } from './pages/AppsPage'
|
import { AppsPage } from './pages/AppsPage'
|
||||||
import { dashboardSections } from './config/dashboard'
|
import { dashboardSections } from './config/dashboard'
|
||||||
|
|
||||||
function DashboardPage() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{dashboardSections.map(({ label, widgets }) => (
|
{dashboardSections.map(({ label, widgets }) => {
|
||||||
|
const isCollapsed = collapsed.has(label)
|
||||||
|
return (
|
||||||
<div key={label}>
|
<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">
|
<div className="infra-grid">
|
||||||
{widgets.map((Widget, i) => <Widget key={i} />)}
|
{widgets.map((Widget, i) => <Widget key={i} />)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
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() {
|
export function Header() {
|
||||||
const [time, setTime] = useState('')
|
const [time, setTime] = useState('')
|
||||||
const [date, setDate] = useState('')
|
const [date, setDate] = useState('')
|
||||||
|
const [weather, setWeather] = useState<{ tempC: number; code: number; desc: string } | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
@@ -16,6 +28,21 @@ export function Header() {
|
|||||||
return () => clearInterval(id)
|
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 (
|
return (
|
||||||
<header>
|
<header>
|
||||||
<div className="logo">
|
<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="/" end className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Dashboard</NavLink>
|
||||||
<NavLink to="/apps" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Apps</NavLink>
|
<NavLink to="/apps" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Apps</NavLink>
|
||||||
</nav>
|
</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-widget">
|
||||||
<div className="clock-time">{time}</div>
|
<div className="clock-time">{time}</div>
|
||||||
<div className="clock-date">{date}</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-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; }
|
.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 ── */
|
||||||
.section-label {
|
.section-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
@@ -81,8 +86,18 @@ header {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
display: flex; align-items: center; gap: 8px;
|
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-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 ── */
|
/* ── Grid ── */
|
||||||
.infra-grid {
|
.infra-grid {
|
||||||
@@ -115,6 +130,18 @@ header {
|
|||||||
}
|
}
|
||||||
.card { animation: fadeUp 0.4s ease both; }
|
.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 ── */
|
||||||
.widget-header {
|
.widget-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
|||||||
Reference in New Issue
Block a user