Add mobile home page with weather and transit widgets
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -17,6 +17,7 @@ import jellyfin from './routes/jellyfin'
|
||||
import navidrome from './routes/navidrome'
|
||||
import romm from './routes/romm'
|
||||
import weather from './routes/weather'
|
||||
import transit from './routes/transit'
|
||||
|
||||
const app = express()
|
||||
const PORT = Number(process.env.PORT ?? 3001)
|
||||
@@ -39,6 +40,7 @@ app.use('/api/jellyfin', jellyfin)
|
||||
app.use('/api/navidrome', navidrome)
|
||||
app.use('/api/romm', romm)
|
||||
app.use('/api/weather', weather)
|
||||
app.use('/api/transit', transit)
|
||||
|
||||
// Serve built frontend in production only
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/departures', async (_req, res) => {
|
||||
try {
|
||||
const stop = process.env.TRANSIT_STOP_VAG ?? 'SCHW'
|
||||
const response = await axios.get(
|
||||
`https://start.vag.de/dm/api/abfahrten.json/vag/${stop}?timespan=90&limitcount=25`,
|
||||
{ timeout: 8000 }
|
||||
)
|
||||
|
||||
const d = response.data
|
||||
const departures = (d.Abfahrten ?? []).map((a: Record<string, unknown>) => ({
|
||||
line: a.Linienname,
|
||||
direction: a.Richtungstext,
|
||||
scheduledTime: a.AbfahrtszeitSoll,
|
||||
realtimeTime: a.AbfahrtszeitIst,
|
||||
realtime: a.Prognose,
|
||||
product: a.Produkt,
|
||||
platform: a.HaltesteigText,
|
||||
}))
|
||||
|
||||
res.json({
|
||||
stop: d.Haltestellenname ?? stop,
|
||||
notices: d.Sonderinformationen ?? [],
|
||||
departures,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
res.status(500).json({ error: msg })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
+22
-10
@@ -13,20 +13,32 @@ router.get('/current', async (_req, res) => {
|
||||
|
||||
const response = await axios.get(
|
||||
`https://wttr.in/${encodeURIComponent(location)}?format=j1`,
|
||||
{ headers: { 'Accept': 'application/json' }, timeout: 8000 }
|
||||
{ headers: { Accept: 'application/json' }, timeout: 8000 }
|
||||
)
|
||||
|
||||
const c = response.data.current_condition[0]
|
||||
const area = response.data.nearest_area?.[0]
|
||||
const data = response.data
|
||||
const c = data.current_condition[0]
|
||||
const area = data.nearest_area?.[0]
|
||||
const today = data.weather?.[0]
|
||||
|
||||
const hourly = (today?.hourly ?? []).map((h: Record<string, unknown>) => ({
|
||||
hour: Math.floor(Number(h.time) / 100),
|
||||
tempC: Number(h.tempC),
|
||||
code: Number(h.weatherCode),
|
||||
desc: (h.weatherDesc as { value: string }[])[0]?.value ?? '',
|
||||
}))
|
||||
|
||||
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,
|
||||
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,
|
||||
todayMin: Number(today?.mintempC ?? c.temp_C),
|
||||
todayMax: Number(today?.maxtempC ?? c.temp_C),
|
||||
hourly,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
|
||||
+13
-3
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Header } from './components/Header'
|
||||
import { AppsPage } from './pages/AppsPage'
|
||||
import { MobileHome } from './pages/MobileHome'
|
||||
import { dashboardSections } from './config/dashboard'
|
||||
import { useIsMobile } from './hooks/useIsMobile'
|
||||
|
||||
function DashboardPage() {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||
@@ -37,6 +39,12 @@ function DashboardPage() {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function RootRedirect() {
|
||||
const isMobile = useIsMobile()
|
||||
return <Navigate to={isMobile ? '/home' : '/dashboard'} replace />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@@ -45,8 +53,10 @@ export default function App() {
|
||||
<div className="shell">
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/home" element={<MobileHome />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -54,8 +54,9 @@ export function Header() {
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<nav className="nav-tabs">
|
||||
<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="/home" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Home</NavLink>
|
||||
<NavLink to="/dashboard" 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">
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useIsMobile(breakpoint = 640) {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mql.addEventListener('change', handler)
|
||||
return () => mql.removeEventListener('change', handler)
|
||||
}, [breakpoint])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
+220
@@ -343,3 +343,223 @@ header {
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
/* ── Mobile Home ── */
|
||||
.mobile-home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.mobile-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
animation: fadeUp 0.4s ease both;
|
||||
}
|
||||
|
||||
.mobile-loading {
|
||||
color: var(--muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Weather */
|
||||
.mobile-weather-card {}
|
||||
|
||||
.mobile-weather-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mobile-weather-emoji { font-size: 48px; line-height: 1; }
|
||||
|
||||
.mobile-temp {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 42px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, var(--text), var(--text2));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-weather-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mobile-weather-minmax {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.mobile-weather-stats {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-stat-sep { color: var(--border); }
|
||||
|
||||
.mobile-hourly-scroll {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mobile-hourly-scroll::-webkit-scrollbar { display: none; }
|
||||
|
||||
.mobile-hour-slot {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface2);
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.mobile-hour-now {
|
||||
background: rgba(79,142,247,0.15);
|
||||
border: 1px solid rgba(79,142,247,0.3);
|
||||
}
|
||||
|
||||
.mobile-hour-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mobile-hour-now .mobile-hour-time { color: var(--accent); }
|
||||
|
||||
.mobile-hour-emoji { font-size: 18px; }
|
||||
|
||||
.mobile-hour-temp {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Transit */
|
||||
.mobile-transit-card {}
|
||||
|
||||
.mobile-transit-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mobile-transit-stop {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mobile-transit-updated {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mobile-refresh-btn {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text2);
|
||||
border-radius: 6px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.mobile-refresh-btn:hover { color: var(--text); }
|
||||
|
||||
.mobile-notices {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mobile-notice {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--yellow);
|
||||
background: rgba(251,191,36,0.08);
|
||||
border: 1px solid rgba(251,191,36,0.2);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mobile-departures-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-departure-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mobile-departure-row:last-child { border-bottom: none; }
|
||||
|
||||
.mobile-line-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-departure-dir {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mobile-departure-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--green);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-departure-time.delayed { color: var(--yellow); }
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/* ── Types ── */
|
||||
interface HourlySlot { hour: number; tempC: number; code: number; desc: string }
|
||||
interface WeatherData {
|
||||
tempC: number; feelsLikeC: number; humidity: number; windKmph: number
|
||||
desc: string; code: number; city: string
|
||||
todayMin: number; todayMax: number; hourly: HourlySlot[]
|
||||
}
|
||||
interface Departure {
|
||||
line: string; direction: string
|
||||
scheduledTime: string; realtimeTime: string
|
||||
realtime: boolean; product: string; platform: string
|
||||
}
|
||||
interface TransitData { stop: string; notices: string[]; departures: Departure[] }
|
||||
|
||||
/* ── Helpers ── */
|
||||
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 '🌡️'
|
||||
}
|
||||
|
||||
function minutesUntil(iso: string): number {
|
||||
return Math.round((new Date(iso).getTime() - Date.now()) / 60000)
|
||||
}
|
||||
|
||||
function formatMinutes(min: number): string {
|
||||
if (min <= 0) return 'now'
|
||||
if (min === 1) return '1 min'
|
||||
return `${min} min`
|
||||
}
|
||||
|
||||
function productColor(product: string): string {
|
||||
if (product === 'UBahn') return '#2563eb'
|
||||
if (product === 'Bus') return '#16a34a'
|
||||
return '#d97706' // Tram
|
||||
}
|
||||
|
||||
/* ── Weather card ── */
|
||||
function MobileWeather() {
|
||||
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="mobile-card mobile-weather-card"><p style={{ color: 'var(--red)', fontSize: 12 }}>⚠ {error}</p></div>
|
||||
if (!data) return <div className="mobile-card mobile-weather-card mobile-loading">Loading weather…</div>
|
||||
|
||||
const now = new Date().getHours()
|
||||
const upcoming = data.hourly.filter(h => h.hour >= now)
|
||||
|
||||
return (
|
||||
<div className="mobile-card mobile-weather-card">
|
||||
{/* Main temp */}
|
||||
<div className="mobile-weather-main">
|
||||
<span className="mobile-weather-emoji">{weatherEmoji(data.code)}</span>
|
||||
<div>
|
||||
<div className="mobile-temp">{data.tempC}°C</div>
|
||||
<div className="mobile-weather-desc">{data.desc}</div>
|
||||
</div>
|
||||
<div className="mobile-weather-minmax">
|
||||
<span>↑{data.todayMax}°</span>
|
||||
<span>↓{data.todayMin}°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="mobile-weather-stats">
|
||||
<span>Feels {data.feelsLikeC}°C</span>
|
||||
<span className="mobile-stat-sep">·</span>
|
||||
<span>{data.humidity}% humidity</span>
|
||||
<span className="mobile-stat-sep">·</span>
|
||||
<span>{data.windKmph} km/h wind</span>
|
||||
</div>
|
||||
|
||||
{/* Hourly scroll */}
|
||||
<div className="mobile-hourly-scroll">
|
||||
{upcoming.map(h => (
|
||||
<div key={h.hour} className={`mobile-hour-slot${h.hour === now ? ' mobile-hour-now' : ''}`}>
|
||||
<div className="mobile-hour-time">{h.hour === now ? 'Now' : `${String(h.hour).padStart(2, '0')}:00`}</div>
|
||||
<div className="mobile-hour-emoji">{weatherEmoji(h.code)}</div>
|
||||
<div className="mobile-hour-temp">{h.tempC}°</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Transit card ── */
|
||||
function MobileTransit() {
|
||||
const [data, setData] = useState<TransitData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [updated, setUpdated] = useState<Date>(new Date())
|
||||
|
||||
const load = () => {
|
||||
fetch('/api/transit/departures').then(r => r.json()).then(d => {
|
||||
if (d.error) setError(d.error); else { setData(d); setUpdated(new Date()) }
|
||||
}).catch(() => setError('Failed to fetch departures'))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const id = setInterval(load, 60000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
if (error) return <div className="mobile-card"><p style={{ color: 'var(--red)', fontSize: 12 }}>⚠ {error}</p></div>
|
||||
if (!data) return <div className="mobile-card mobile-loading">Loading departures…</div>
|
||||
|
||||
return (
|
||||
<div className="mobile-card mobile-transit-card">
|
||||
<div className="mobile-transit-header">
|
||||
<div>
|
||||
<div className="mobile-transit-stop">{data.stop}</div>
|
||||
<div className="mobile-transit-updated">
|
||||
Updated {updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<button className="mobile-refresh-btn" onClick={load}>↻</button>
|
||||
</div>
|
||||
|
||||
{data.notices.length > 0 && (
|
||||
<div className="mobile-notices">
|
||||
{data.notices.map((n, i) => (
|
||||
<div key={i} className="mobile-notice">⚠ {n}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mobile-departures-list">
|
||||
{data.departures.map((d, i) => {
|
||||
const min = minutesUntil(d.realtimeTime)
|
||||
const delayed = d.realtime && new Date(d.realtimeTime) > new Date(d.scheduledTime)
|
||||
if (min < -1) return null
|
||||
return (
|
||||
<div key={i} className="mobile-departure-row">
|
||||
<div
|
||||
className="mobile-line-badge"
|
||||
style={{ background: productColor(d.product) }}
|
||||
>
|
||||
{d.line}
|
||||
</div>
|
||||
<div className="mobile-departure-dir">{d.direction}</div>
|
||||
<div className={`mobile-departure-time${delayed ? ' delayed' : ''}`}>
|
||||
{formatMinutes(min)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Page ── */
|
||||
export function MobileHome() {
|
||||
return (
|
||||
<div className="mobile-home">
|
||||
<MobileWeather />
|
||||
<MobileTransit />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user