feat: update Dashboard and Settings pages with new widgets and layout system
This commit is contained in:
parent
91092570f0
commit
6380a89808
3 changed files with 453 additions and 80 deletions
10
src/main.tsx
10
src/main.tsx
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue