+ {/* 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 }) => (
+
+);
+
+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 */}
+
+
+ {/* 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
+ )}
+
+ >
+ ) : (
+
+ )}
+
+
+
);