feat: update Dashboard and Settings pages with new widgets and layout system

This commit is contained in:
cediackermann 2026-04-20 18:27:24 +02:00
parent 91092570f0
commit 6380a89808
3 changed files with 453 additions and 80 deletions

View file

@ -1,8 +1,14 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { App } from './App'; import { App } from './App';
const queryClient = new QueryClient();
const container = document.getElementById('root'); const container = document.getElementById('root');
if (container) { if (container) {
const root = createRoot(container); createRoot(container).render(
root.render(<App />); <QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
} }

View file

@ -1,67 +1,146 @@
import { useState, useEffect } from 'react'; import { useEffect, useReducer } from 'react';
import { Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query';
import { Clock } from '../components/molecules/Clock'; import { Clock } from '../components/molecules/Clock';
import { DepartureList } from '../components/organisms/DepartureList'; import { DepartureList } from '../components/organisms/DepartureList';
import { Config, Departure } from '../types'; import { SBBDepartureList } from '../components/organisms/SBBDepartureList';
import { SpotifyWidget } from '../components/organisms/SpotifyWidget';
import { WeatherWidget } from '../components/organisms/WeatherWidget';
import { SystemWidget } from '../components/organisms/SystemWidget';
import { AlwaysOnContext } from '../contexts/AlwaysOnContext';
import { Config, Departure, SBBDeparture, WidgetId, ZoneId } from '../types';
const fetchJson = (url: string) => fetch(url).then(r => r.json());
const FOOTER_ZONES: ZoneId[] = ['footer-1', 'footer-2', 'footer-3'];
const DEPARTURE_WIDGETS: WidgetId[] = ['sl', 'sbb'];
export const Dashboard = () => { export const Dashboard = () => {
const [config, setConfig] = useState<Config | null>(null); const { data: config } = useQuery<Config>({
const [departures, setDepartures] = useState<Departure[]>([]); queryKey: ['config'],
const [loading, setLoading] = useState(true); queryFn: () => fetchJson('/api/config'),
});
const alwaysOn = !!config?.alwaysOn;
const layout = config?.layout ?? {};
const inLayout = (id: WidgetId) => Object.values(layout).includes(id);
const [, tick] = useReducer(x => x + 1, 0);
useEffect(() => { useEffect(() => {
const loadConfig = async () => { if (!alwaysOn) return;
try { let id: ReturnType<typeof setInterval>;
const res = await fetch('/api/config'); const schedule = () => {
const data = await res.json(); const now = new Date();
setConfig(data); const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
} catch (e) { return setTimeout(() => { tick(); id = setInterval(tick, 60_000); }, msUntilNext);
console.error('Failed to load config', e);
}
}; };
loadConfig(); const timeout = schedule();
}, []); return () => { clearTimeout(timeout); clearInterval(id); };
}, [alwaysOn]);
const fetchDepartures = async () => { const { data: slData, isLoading: slLoading } = useQuery<{ departures: Departure[] }>({
if (!config?.stationId) return; queryKey: ['sl-departures', config?.sl?.stationId],
setLoading(true); queryFn: () => fetchJson(`/api/sl/departures/${config!.sl.stationId}`),
try { enabled: inLayout('sl') && !!config?.sl?.stationId,
const res = await fetch(`/api/sl/departures/${config.stationId}`); refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000),
const data = await res.json(); });
setDepartures(data.departures || []);
} catch (e) { const { data: sbbData, isLoading: sbbLoading } = useQuery<{ stationboard: SBBDeparture[] }>({
console.error('Failed to fetch departures', e); queryKey: ['sbb-departures', config?.sbb?.stationId],
} finally { queryFn: () => fetchJson(`/api/sbb/departures/${config!.sbb.stationId}`),
setLoading(false); enabled: inLayout('sbb') && !!config?.sbb?.stationId,
refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000),
});
const renderWidget = (id: WidgetId | undefined) => {
if (!id || id === 'spacer') return null;
switch (id) {
case 'clock': return <div className="h-full flex items-center"><Clock /></div>;
case 'weather': return <div className="h-full flex items-center"><WeatherWidget /></div>;
case 'spotify': return <SpotifyWidget />;
case 'system': return <SystemWidget />;
case 'sl':
return (
<div className="h-full flex flex-col">
<h2 className={`text-xl uppercase tracking-wider shrink-0 pb-1 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
{slData?.departures[0]?.stop_area?.name || config?.sl?.stationName || 'SL'}
</h2>
<DepartureList departures={slData?.departures ?? []} loading={slLoading} />
</div>
);
case 'sbb':
return (
<div className="h-full flex flex-col">
<h2 className={`text-xl uppercase tracking-wider shrink-0 pb-1 ${alwaysOn ? 'font-normal' : 'font-bold'}`}>
{config?.sbb?.stationName || 'SBB'}
</h2>
<SBBDepartureList departures={sbbData?.stationboard ?? []} loading={sbbLoading} />
</div>
);
} }
}; };
useEffect(() => { const headerLeft = layout['header-left'];
if (config) { const headerRight = layout['header-right'];
fetchDepartures(); const main1 = layout['main-1'];
const interval = setInterval(fetchDepartures, config.refreshRate); const main2 = layout['main-2'];
return () => clearInterval(interval); // Keep spacers — they hold intentional empty slots, only collapse truly unset zones
} const footerWidgets = FOOTER_ZONES.map(z => layout[z]).filter(w => w != null) as WidgetId[];
}, [config]);
const hasHeader = headerLeft || headerRight;
// spacer alone doesn't count as real content
const hasMain = (main1 && main1 !== 'spacer') || (main2 && main2 !== 'spacer');
const hasFooter = footerWidgets.filter(w => w !== 'spacer').length > 0;
const footerHasDeps = footerWidgets.some(w => DEPARTURE_WIDGETS.includes(w));
// Footer sizing: takes 40% of viewport when holding departure lists so rows are visible.
// When only info widgets: auto-height. When no main: expands to fill.
const footerStyle: React.CSSProperties = !hasMain
? { flex: '1 1 0', minHeight: 0 }
: footerHasDeps
? { flex: '0 0 40%', minHeight: 0 }
: { flex: '0 0 auto', minHeight: '140px' };
// Zone wrapper: min-h-0 prevents a child from inflating beyond the slot
const zoneClass = 'min-h-0 min-w-0 overflow-hidden';
return ( return (
<div className="flex flex-col h-screen w-screen p-12 overflow-hidden gap-12"> <AlwaysOnContext.Provider value={alwaysOn}>
<div className="flex justify-between items-start border-b border-zinc-800 pb-10"> <div className="flex flex-col h-screen w-screen p-6 gap-4 overflow-hidden">
<Clock />
<Link {hasHeader && (
to="/settings" <div className="flex items-center justify-between border-b border-white pb-4 shrink-0">
className="p-4 bg-surface rounded-2xl text-3xl hover:bg-zinc-800 transition-all hover:scale-105 active:scale-95" <div className={`flex-1 ${zoneClass}`}>{renderWidget(headerLeft)}</div>
> {headerRight && <div className={`flex-shrink-0 ml-8 ${zoneClass}`}>{renderWidget(headerRight)}</div>}
</div>
</Link> )}
</div>
{hasMain && (
<div
className={`${main1 && main2 ? 'grid grid-cols-2 gap-6' : 'flex'}`}
style={{ flex: '1 1 0', minHeight: 0 }}
>
{main1 && <div className={`${main1 && main2 ? '' : 'flex-1'} ${zoneClass}`}>{renderWidget(main1)}</div>}
{main2 && <div className={zoneClass}>{renderWidget(main2)}</div>}
</div>
)}
{hasFooter && (
<div
className="border-t border-white pt-4 grid gap-6"
style={{
...footerStyle,
gridTemplateColumns: `repeat(${footerWidgets.length}, 1fr)`,
}}
>
{footerWidgets.map((w, i) => (
<div key={`${w}-${i}`} className={`${zoneClass} h-full`}>{renderWidget(w)}</div>
))}
</div>
)}
<div className="flex-1 overflow-hidden">
<h2 className="text-4xl font-bold mb-8 text-sl-blue uppercase tracking-widest">
{departures[0]?.stop_area?.name || config?.stationName || 'Departures'}
</h2>
<DepartureList departures={departures} loading={loading} />
</div> </div>
</div> </AlwaysOnContext.Provider>
); );
}; };

View file

@ -1,42 +1,330 @@
import { Link, useNavigate } from 'react-router-dom'; import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { SearchBox } from '../components/organisms/SearchBox'; import { SearchBox } from '../components/organisms/SearchBox';
import { StopLocation } from '../types'; import { Config, StopLocation, WidgetId, WidgetLayout, ZoneId } from '../types';
export const Settings = () => { // ── Layout Editor ─────────────────────────────────────────────────────────────
const navigate = useNavigate();
const handleSelect = async (stop: StopLocation) => { const ALL_WIDGETS: WidgetId[] = ['clock', 'weather', 'sl', 'sbb', 'spotify', 'system', 'spacer'];
if (!confirm(`Do you want to change the station to "${stop.name}"?`)) return;
try { const WIDGET_LABELS: Record<WidgetId, string> = {
const response = await fetch('/api/config', { clock: 'Clock', weather: 'Weather', sl: 'SL Departures',
method: 'POST', sbb: 'SBB Departures', spotify: 'Spotify', system: 'System', spacer: 'Spacer',
headers: { 'Content-Type': 'application/json' }, };
body: JSON.stringify({ stationId: stop.id, stationName: stop.name, refreshRate: 30000 })
});
if (response.ok) { const ZONE_LABELS: Record<ZoneId, string> = {
navigate('/'); 'header-left': 'Header Left', 'header-right': 'Header Right',
} 'main-1': 'Main Col 1', 'main-2': 'Main Col 2',
} catch (e) { 'footer-1': 'Footer 1', 'footer-2': 'Footer 2', 'footer-3': 'Footer 3',
alert('Could not save settings.'); };
// 6-col grid positions for the visual layout preview
const ZONE_GRID_STYLE: Record<ZoneId, React.CSSProperties> = {
'header-left': { gridColumn: '1 / 4', gridRow: '1' },
'header-right': { gridColumn: '4 / 7', gridRow: '1' },
'main-1': { gridColumn: '1 / 4', gridRow: '2' },
'main-2': { gridColumn: '4 / 7', gridRow: '2' },
'footer-1': { gridColumn: '1 / 3', gridRow: '3' },
'footer-2': { gridColumn: '3 / 5', gridRow: '3' },
'footer-3': { gridColumn: '5 / 7', gridRow: '3' },
};
const ALL_ZONES: ZoneId[] = ['header-left', 'header-right', 'main-1', 'main-2', 'footer-1', 'footer-2', 'footer-3'];
interface LayoutEditorProps {
layout: WidgetLayout;
onChange: (l: WidgetLayout) => void;
}
const LayoutEditor = ({ layout, onChange }: LayoutEditorProps) => {
const [dragOver, setDragOver] = useState<ZoneId | 'palette' | null>(null);
const placedSet = new Set(Object.values(layout).filter(Boolean) as WidgetId[]);
// Spacer can be placed multiple times; all other widgets can only appear once
const paletteWidgets = ALL_WIDGETS.filter(w => w === 'spacer' || !placedSet.has(w));
const startDrag = (e: React.DragEvent, widgetId: WidgetId, fromZone: ZoneId | 'palette') => {
e.dataTransfer.setData('widgetId', widgetId);
e.dataTransfer.setData('fromZone', fromZone);
};
const dropOnZone = (e: React.DragEvent, targetZone: ZoneId) => {
e.preventDefault();
setDragOver(null);
const widgetId = e.dataTransfer.getData('widgetId') as WidgetId;
const fromZone = e.dataTransfer.getData('fromZone') as ZoneId | 'palette';
if (!widgetId) return;
const next = { ...layout };
const existing = next[targetZone];
if (fromZone !== 'palette') {
// Swap: place existing widget into the source zone
if (existing) next[fromZone as ZoneId] = existing;
else delete next[fromZone as ZoneId];
} }
next[targetZone] = widgetId;
onChange(next);
};
const dropOnPalette = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(null);
const fromZone = e.dataTransfer.getData('fromZone') as ZoneId | 'palette';
if (fromZone === 'palette') return;
const next = { ...layout };
delete next[fromZone as ZoneId];
onChange(next);
};
const removeFromZone = (zone: ZoneId) => {
const next = { ...layout };
delete next[zone];
onChange(next);
}; };
return ( return (
<div className="flex flex-col h-screen w-screen p-12 overflow-y-auto bg-black"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-8 mb-12"> {/* Visual grid */}
<Link to="/" className="text-4xl text-gray-500 hover:text-white transition-colors"> <div
style={{
</Link> display: 'grid',
<h1 className="text-5xl font-bold">Settings</h1> gridTemplateColumns: 'repeat(6, 1fr)',
gridTemplateRows: 'auto minmax(100px, auto) auto',
gap: '4px',
}}
>
{ALL_ZONES.map(zone => {
const widgetId = layout[zone];
const over = dragOver === zone;
return (
<div
key={zone}
style={ZONE_GRID_STYLE[zone]}
className={`border p-2 flex flex-col gap-1 min-h-[56px] transition-colors ${over ? 'border-white bg-zinc-900' : 'border-zinc-700'}`}
onDragOver={e => { e.preventDefault(); setDragOver(zone); }}
onDragLeave={() => setDragOver(null)}
onDrop={e => dropOnZone(e, zone)}
>
<span className="text-[10px] text-gray-600 uppercase tracking-wider leading-none">{ZONE_LABELS[zone]}</span>
{widgetId ? (
<div className="flex items-center justify-between mt-auto">
<span
draggable
onDragStart={e => startDrag(e, widgetId, zone)}
className={`text-sm font-bold cursor-grab px-2 py-0.5 select-none ${widgetId === 'spacer' ? 'border border-dashed border-zinc-600 text-zinc-500' : 'border border-zinc-500'}`}
>
{WIDGET_LABELS[widgetId]}
</span>
<button
onClick={() => removeFromZone(zone)}
className="text-gray-600 hover:text-white text-base leading-none ml-1"
>×</button>
</div>
) : (
<span className="text-[10px] text-zinc-700 mt-auto">drop here</span>
)}
</div>
);
})}
</div> </div>
<div className="max-w-4xl w-full mx-auto"> {/* Palette */}
<h2 className="text-2xl text-gray-400 mb-8 px-4 font-medium uppercase tracking-widest"> <div
Search for your station className={`border p-3 min-h-[52px] transition-colors ${dragOver === 'palette' ? 'border-white bg-zinc-900' : 'border-zinc-700'}`}
</h2> onDragOver={e => { e.preventDefault(); setDragOver('palette'); }}
<SearchBox onSelect={handleSelect} /> onDragLeave={() => setDragOver(null)}
onDrop={dropOnPalette}
>
<span className="text-xs text-gray-500 uppercase tracking-wider block mb-2">Available (drag to dashboard)</span>
<div className="flex flex-wrap gap-2">
{paletteWidgets.map(w => (
<span
key={w}
draggable
onDragStart={e => startDrag(e, w, 'palette')}
className={`text-sm font-bold cursor-grab px-3 py-1 select-none ${w === 'spacer' ? 'border border-dashed border-zinc-600 text-zinc-500' : 'border border-zinc-500'}`}
>
{WIDGET_LABELS[w]}
</span>
))}
{paletteWidgets.length === 0 && (
<span className="text-xs text-zinc-700">all widgets placed drag from grid to remove</span>
)}
</div>
</div>
</div>
);
};
// ── Shared UI ─────────────────────────────────────────────────────────────────
const Toggle = ({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) => (
<button onClick={() => onChange(!enabled)} className="border border-white px-4 py-1 text-lg font-bold">
{enabled ? 'ON' : 'OFF'}
</button>
);
const Section = ({ title, children, fullWidth }: { title: string; children: React.ReactNode; fullWidth?: boolean }) => (
<section className="border-b border-zinc-800 pb-8" style={fullWidth ? { gridColumn: '1 / -1' } : undefined}>
<h2 className="text-2xl font-bold mb-4">{title}</h2>
{children}
</section>
);
const Field = ({ label, value, onChange, type = 'text', placeholder = '' }: {
label: string; value: string; onChange: (v: string) => void; type?: string; placeholder?: string;
}) => (
<div className="flex flex-col gap-1 mb-3">
<label className="text-sm text-gray-400 uppercase tracking-wider">{label}</label>
<input
type={type}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="bg-black text-white px-4 py-2 border border-zinc-700 outline-none text-lg focus:border-white"
/>
</div>
);
// ── Page ──────────────────────────────────────────────────────────────────────
export const Settings = () => {
const qc = useQueryClient();
const { data: config } = useQuery<Config>({
queryKey: ['config'],
queryFn: () => fetch('/api/config').then(r => r.json()),
});
const { data: spotifyStatus, refetch: refetchSpotifyStatus } = useQuery<{ connected: boolean }>({
queryKey: ['spotify-status'],
queryFn: () => fetch('/api/spotify/status').then(r => r.json()),
});
const { mutate: save } = useMutation({
mutationFn: (updated: Config) =>
fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated) }),
onMutate: (updated) => qc.setQueryData(['config'], updated),
});
const [localLayout, setLocalLayout] = useState<WidgetLayout>({});
useEffect(() => { if (config) setLocalLayout(config.layout ?? {}); }, [config]);
const [spotifyClientId, setSpotifyClientId] = useState('');
const [spotifyClientSecret, setSpotifyClientSecret] = useState('');
const [weatherPlz, setWeatherPlz] = useState('');
if (!config) return <div className="p-8 text-gray-500">Loading...</div>;
const saveSpotifyCredentials = () => {
save({ ...config, spotify: { clientId: spotifyClientId || config.spotify.clientId, clientSecret: spotifyClientSecret || config.spotify.clientSecret } });
};
const disconnectSpotify = async () => {
await fetch('/api/spotify/disconnect');
refetchSpotifyStatus();
};
const handleLayoutChange = (newLayout: WidgetLayout) => {
setLocalLayout(newLayout);
save({ ...config, layout: newLayout });
};
return (
<div className="flex flex-col h-screen w-screen p-8 overflow-auto bg-black text-white">
<div className="flex items-center gap-6 mb-8 border-b border-white pb-4">
<Link to="/" className="text-2xl"></Link>
<h1 className="text-4xl font-bold">Settings</h1>
</div>
<div className="grid gap-8" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 420px), 1fr))' }}>
{/* Always-on mode */}
<Section title="Always-on Mode" fullWidth>
<div className="flex items-center justify-between">
<p className="text-gray-400">Minute-aligned updates, thinner text, no progress bars</p>
<Toggle enabled={config.alwaysOn ?? false} onChange={v => save({ ...config, alwaysOn: v })} />
</div>
</Section>
{/* Layout */}
<Section title="Dashboard Layout" fullWidth>
<p className="text-gray-400 mb-4 text-sm">Drag widgets into zones. Drag back to the palette to hide. Zones can be swapped by dropping onto an occupied zone.</p>
<LayoutEditor layout={localLayout} onChange={handleLayoutChange} />
</Section>
{/* SL */}
<Section title="SL Departures">
<p className="text-gray-400 mb-3">{config.sl.stationName || 'No station selected'}</p>
<SearchBox
endpoint="/api/sl/search"
placeholder="Search SL station..."
onSelect={(stop: StopLocation) => save({ ...config, sl: { stationId: stop.id, stationName: stop.name } })}
/>
</Section>
{/* SBB */}
<Section title="SBB Departures">
<p className="text-gray-400 mb-3">{config.sbb.stationName || 'No station selected'}</p>
<SearchBox
endpoint="/api/sbb/search"
placeholder="Search SBB station..."
onSelect={(stop: StopLocation) => save({ ...config, sbb: { stationId: stop.id, stationName: stop.name } })}
/>
</Section>
{/* Weather */}
<Section title="Weather (Meteoswiss)">
<p className="text-gray-400 mb-3">{config.weather.plz ? `PLZ ${config.weather.plz}` : 'No PLZ configured'}</p>
<div className="flex gap-2">
<input
type="text"
value={weatherPlz || config.weather.plz}
onChange={e => setWeatherPlz(e.target.value)}
placeholder="e.g. 8001"
maxLength={4}
className="bg-black text-white px-4 py-2 border border-zinc-700 outline-none text-lg focus:border-white w-32"
/>
<button
onClick={() => { save({ ...config, weather: { plz: weatherPlz || config.weather.plz } }); setWeatherPlz(''); }}
className="border border-white px-4 py-2 font-bold"
>
Save
</button>
</div>
</Section>
{/* Spotify */}
<Section title="Spotify Now Playing">
<p className="text-gray-400 mb-4">
{spotifyStatus?.connected ? 'Connected' : config.spotify.clientId ? 'Not connected' : 'Not configured'}
</p>
<div className="flex flex-col gap-4">
{!spotifyStatus?.connected ? (
<>
<p className="text-sm text-gray-500">
Create an app at <span className="text-white">developer.spotify.com</span> and add{' '}
<span className="text-white font-mono text-xs">http://127.0.0.1:3000/api/spotify/callback</span> as a redirect URI.
</p>
<Field label="Client ID" value={spotifyClientId} onChange={setSpotifyClientId} placeholder={config.spotify.clientId ? '(saved)' : ''} />
<Field label="Client Secret" value={spotifyClientSecret} onChange={setSpotifyClientSecret} type="password" placeholder={config.spotify.clientSecret ? '(saved)' : ''} />
<div className="flex gap-2">
<button onClick={saveSpotifyCredentials} className="border border-white px-4 py-2 font-bold">Save Credentials</button>
{config.spotify.clientId && (
<a href="/api/spotify/auth" className="border border-white px-4 py-2 font-bold">Connect Spotify</a>
)}
</div>
</>
) : (
<button onClick={disconnectSpotify} className="border border-zinc-600 px-4 py-2 text-gray-400 w-fit">
Disconnect
</button>
)}
</div>
</Section>
</div> </div>
</div> </div>
); );