Add mobile home page with weather and transit widgets
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-16 16:33:51 +02:00
parent 7bbefaa1f6
commit 13339b17bf
8 changed files with 486 additions and 15 deletions
+13 -3
View File
@@ -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>
+3 -2
View File
@@ -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">
+16
View File
@@ -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
View File
@@ -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); }
+174
View File
@@ -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>
)
}