diff --git a/src/main.tsx b/src/main.tsx index 8c9cab5..ee6d8b6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,14 @@ import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { App } from './App'; +const queryClient = new QueryClient(); + const container = document.getElementById('root'); if (container) { - const root = createRoot(container); - root.render(); + createRoot(container).render( + + + + ); } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 0dc6c54..440fe22 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,67 +1,146 @@ -import { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; +import { useEffect, useReducer } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Clock } from '../components/molecules/Clock'; 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 = () => { - const [config, setConfig] = useState(null); - const [departures, setDepartures] = useState([]); - const [loading, setLoading] = useState(true); + const { data: config } = useQuery({ + queryKey: ['config'], + 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(() => { - const loadConfig = async () => { - try { - const res = await fetch('/api/config'); - const data = await res.json(); - setConfig(data); - } catch (e) { - console.error('Failed to load config', e); - } + if (!alwaysOn) return; + let id: ReturnType; + const schedule = () => { + const now = new Date(); + const msUntilNext = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); + return setTimeout(() => { tick(); id = setInterval(tick, 60_000); }, msUntilNext); }; - loadConfig(); - }, []); + const timeout = schedule(); + return () => { clearTimeout(timeout); clearInterval(id); }; + }, [alwaysOn]); - const fetchDepartures = async () => { - if (!config?.stationId) return; - setLoading(true); - try { - const res = await fetch(`/api/sl/departures/${config.stationId}`); - const data = await res.json(); - setDepartures(data.departures || []); - } catch (e) { - console.error('Failed to fetch departures', e); - } finally { - setLoading(false); + const { data: slData, isLoading: slLoading } = useQuery<{ departures: Departure[] }>({ + queryKey: ['sl-departures', config?.sl?.stationId], + queryFn: () => fetchJson(`/api/sl/departures/${config!.sl.stationId}`), + enabled: inLayout('sl') && !!config?.sl?.stationId, + refetchInterval: alwaysOn ? 60_000 : (config?.refreshRate ?? 30_000), + }); + + const { data: sbbData, isLoading: sbbLoading } = useQuery<{ stationboard: SBBDeparture[] }>({ + queryKey: ['sbb-departures', config?.sbb?.stationId], + queryFn: () => fetchJson(`/api/sbb/departures/${config!.sbb.stationId}`), + 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
; + case 'weather': return
; + case 'spotify': return ; + case 'system': return ; + case 'sl': + return ( +
+

+ {slData?.departures[0]?.stop_area?.name || config?.sl?.stationName || 'SL'} +

+ +
+ ); + case 'sbb': + return ( +
+

+ {config?.sbb?.stationName || 'SBB'} +

+ +
+ ); } }; - useEffect(() => { - if (config) { - fetchDepartures(); - const interval = setInterval(fetchDepartures, config.refreshRate); - return () => clearInterval(interval); - } - }, [config]); + const headerLeft = layout['header-left']; + const headerRight = layout['header-right']; + const main1 = layout['main-1']; + const main2 = layout['main-2']; + // 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[]; + + 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 ( -
-
- - - ⚙️ - -
+ +
+ + {hasHeader && ( +
+
{renderWidget(headerLeft)}
+ {headerRight &&
{renderWidget(headerRight)}
} +
+ )} + + {hasMain && ( +
+ {main1 &&
{renderWidget(main1)}
} + {main2 &&
{renderWidget(main2)}
} +
+ )} + + {hasFooter && ( +
+ {footerWidgets.map((w, i) => ( +
{renderWidget(w)}
+ ))} +
+ )} -
-

- {departures[0]?.stop_area?.name || config?.stationName || 'Departures'} -

-
-
+
); }; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 530bf20..37ba723 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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 { StopLocation } from '../types'; +import { Config, StopLocation, WidgetId, WidgetLayout, ZoneId } from '../types'; -export const Settings = () => { - const navigate = useNavigate(); +// ── Layout Editor ───────────────────────────────────────────────────────────── - const handleSelect = async (stop: StopLocation) => { - if (!confirm(`Do you want to change the station to "${stop.name}"?`)) return; +const ALL_WIDGETS: WidgetId[] = ['clock', 'weather', 'sl', 'sbb', 'spotify', 'system', 'spacer']; - try { - const response = await fetch('/api/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ stationId: stop.id, stationName: stop.name, refreshRate: 30000 }) - }); +const WIDGET_LABELS: Record = { + clock: 'Clock', weather: 'Weather', sl: 'SL Departures', + sbb: 'SBB Departures', spotify: 'Spotify', system: 'System', spacer: 'Spacer', +}; - if (response.ok) { - navigate('/'); - } - } catch (e) { - alert('Could not save settings.'); +const ZONE_LABELS: Record = { + 'header-left': 'Header Left', 'header-right': 'Header Right', + 'main-1': 'Main Col 1', 'main-2': 'Main Col 2', + 'footer-1': 'Footer 1', 'footer-2': 'Footer 2', 'footer-3': 'Footer 3', +}; + +// 6-col grid positions for the visual layout preview +const ZONE_GRID_STYLE: Record = { + '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(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 ( -
-
- - ← - -

Settings

+
+ {/* Visual grid */} +
+ {ALL_ZONES.map(zone => { + const widgetId = layout[zone]; + const over = dragOver === zone; + return ( +
{ e.preventDefault(); setDragOver(zone); }} + onDragLeave={() => setDragOver(null)} + onDrop={e => dropOnZone(e, zone)} + > + {ZONE_LABELS[zone]} + {widgetId ? ( +
+ 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]} + + +
+ ) : ( + drop here + )} +
+ ); + })}
-
-

- Search for your station -

- + {/* Palette */} +
{ e.preventDefault(); setDragOver('palette'); }} + onDragLeave={() => setDragOver(null)} + onDrop={dropOnPalette} + > + Available (drag to dashboard) +
+ {paletteWidgets.map(w => ( + 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]} + + ))} + {paletteWidgets.length === 0 && ( + all widgets placed — drag from grid to remove + )} +
+
+
+ ); +}; + +// ── Shared UI ───────────────────────────────────────────────────────────────── + +const Toggle = ({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) => ( + +); + +const Section = ({ title, children, fullWidth }: { title: string; children: React.ReactNode; fullWidth?: boolean }) => ( +
+

{title}

+ {children} +
+); + +const Field = ({ label, value, onChange, type = 'text', placeholder = '' }: { + label: string; value: string; onChange: (v: string) => void; type?: string; placeholder?: string; +}) => ( +
+ + 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" + /> +
+); + +// ── Page ────────────────────────────────────────────────────────────────────── + +export const Settings = () => { + const qc = useQueryClient(); + + const { data: config } = useQuery({ + 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({}); + useEffect(() => { if (config) setLocalLayout(config.layout ?? {}); }, [config]); + + const [spotifyClientId, setSpotifyClientId] = useState(''); + const [spotifyClientSecret, setSpotifyClientSecret] = useState(''); + const [weatherPlz, setWeatherPlz] = useState(''); + + if (!config) return
Loading...
; + + 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 ( +
+
+ ← +

Settings

+
+ +
+ + {/* Always-on mode */} +
+
+

Minute-aligned updates, thinner text, no progress bars

+ save({ ...config, alwaysOn: v })} /> +
+
+ + {/* Layout */} +
+

Drag widgets into zones. Drag back to the palette to hide. Zones can be swapped by dropping onto an occupied zone.

+ +
+ + {/* SL */} +
+

{config.sl.stationName || 'No station selected'}

+ save({ ...config, sl: { stationId: stop.id, stationName: stop.name } })} + /> +
+ + {/* SBB */} +
+

{config.sbb.stationName || 'No station selected'}

+ save({ ...config, sbb: { stationId: stop.id, stationName: stop.name } })} + /> +
+ + {/* Weather */} +
+

{config.weather.plz ? `PLZ ${config.weather.plz}` : 'No PLZ configured'}

+
+ 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" + /> + +
+
+ + {/* Spotify */} +
+

+ {spotifyStatus?.connected ? 'Connected' : config.spotify.clientId ? 'Not connected' : 'Not configured'} +

+
+ {!spotifyStatus?.connected ? ( + <> +

+ Create an app at developer.spotify.com and add{' '} + http://127.0.0.1:3000/api/spotify/callback as a redirect URI. +

+ + +
+ + {config.spotify.clientId && ( + Connect Spotify + )} +
+ + ) : ( + + )} +
+
+
);