Skip to content

Commit 8e65bba

Browse files
committed
Add light/dark theme and toggle to data-downloadaer
1 parent a0f8bc6 commit 8e65bba

4 files changed

Lines changed: 265 additions & 88 deletions

File tree

server/installer/data-downloader/frontend/index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@
55
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>DAQ Data Downloader</title>
8+
<script>
9+
// Apply theme before first paint to avoid a flash of the wrong theme.
10+
(function () {
11+
try {
12+
var saved = localStorage.getItem("theme");
13+
var theme =
14+
saved === "light" || saved === "dark"
15+
? saved
16+
: window.matchMedia("(prefers-color-scheme: dark)").matches
17+
? "dark"
18+
: "light";
19+
document.documentElement.setAttribute("data-theme", theme);
20+
} catch (e) {}
21+
})();
22+
</script>
823
</head>
924
<body>
1025
<div id="root"></div>

server/installer/data-downloader/frontend/src/App.tsx

Lines changed: 58 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { fetchRuns, fetchSensors, fetchScannerStatus, triggerScan, updateNote, fetchSeasons } from "./api";
3+
import { Moon, Sun } from "lucide-react";
34
import { RunRecord, RunsResponse, ScannerStatus, SensorsResponse, Season } from "./types";
45
import { RunTable } from "./components/RunTable";
56
import { DataDownload } from "./components/data-download";
67

78
type ScanState = "idle" | "running" | "success" | "error";
9+
type Theme = "light" | "dark";
10+
11+
function getInitialTheme(): Theme {
12+
if (typeof window === "undefined") return "light";
13+
const saved = window.localStorage.getItem("theme");
14+
if (saved === "light" || saved === "dark") return saved;
15+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
16+
}
817

918
interface DownloaderSelection {
1019
runKey?: string;
@@ -27,6 +36,7 @@ export default function App() {
2736
const [scanSeason, setScanSeason] = useState<string>("");
2837
const [downloaderSelection, setDownloaderSelection] = useState<DownloaderSelection | null>(null);
2938
const [scannerStatus, setScannerStatus] = useState<ScannerStatus | null>(null);
39+
const [theme, setTheme] = useState<Theme>(getInitialTheme);
3040
const sensorsSectionRef = useRef<HTMLElement | null>(null);
3141
const downloaderSectionRef = useRef<HTMLElement | null>(null);
3242
const statusFinishedRef = useRef<string | null>(null);
@@ -89,6 +99,11 @@ export default function App() {
8999
[loadData]
90100
);
91101

102+
useEffect(() => {
103+
document.documentElement.setAttribute("data-theme", theme);
104+
window.localStorage.setItem("theme", theme);
105+
}, [theme]);
106+
92107
useEffect(() => {
93108
void loadData();
94109
void loadStatus(false);
@@ -230,36 +245,46 @@ export default function App() {
230245
</p>
231246
</div>
232247

233-
{seasons.length > 0 && (
234-
<div style={{ textAlign: "right" }}>
235-
<p style={{ fontSize: "0.7rem", color: "#6b7280", margin: "0 0 0.25rem 0", textTransform: "uppercase", letterSpacing: "0.05em" }}>Active Season</p>
236-
<div style={{ display: "flex", flexWrap: "wrap", gap: "0", border: "1px solid #e5e7eb", borderRadius: "6px", overflow: "hidden", maxWidth: "320px" }}>
237-
{seasons.map(s => {
238-
const active = s.name === selectedSeason;
239-
const sc = seasonColor(s.name);
240-
return (
241-
<button
242-
key={s.name}
243-
onClick={() => setSelectedSeason(s.name)}
244-
style={{
245-
padding: "0.35rem 0.75rem",
246-
border: "none",
247-
borderRight: "1px solid #e5e7eb",
248-
background: active ? sc : "transparent",
249-
color: active ? "#fff" : sc,
250-
fontWeight: active ? "bold" : "normal",
251-
fontSize: "0.85rem",
252-
cursor: "pointer",
253-
transition: "background 0.15s",
254-
}}
255-
>
256-
{s.name}
257-
</button>
258-
);
259-
})}
248+
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}>
249+
{seasons.length > 0 && (
250+
<div style={{ textAlign: "right" }}>
251+
<p style={{ fontSize: "0.7rem", color: "var(--text-muted)", margin: "0 0 0.25rem 0", textTransform: "uppercase", letterSpacing: "0.05em" }}>Active Season</p>
252+
<div style={{ display: "flex", flexWrap: "wrap", gap: "0", border: "1px solid var(--border)", borderRadius: "6px", overflow: "hidden", maxWidth: "320px" }}>
253+
{seasons.map(s => {
254+
const active = s.name === selectedSeason;
255+
const sc = seasonColor(s.name);
256+
return (
257+
<button
258+
key={s.name}
259+
onClick={() => setSelectedSeason(s.name)}
260+
style={{
261+
padding: "0.35rem 0.75rem",
262+
border: "none",
263+
borderRight: "1px solid var(--border)",
264+
background: active ? sc : "transparent",
265+
color: active ? "#fff" : sc,
266+
fontWeight: active ? "bold" : "normal",
267+
fontSize: "0.85rem",
268+
cursor: "pointer",
269+
transition: "background 0.15s",
270+
}}
271+
>
272+
{s.name}
273+
</button>
274+
);
275+
})}
276+
</div>
260277
</div>
261-
</div>
262-
)}
278+
)}
279+
<button
280+
className="theme-toggle"
281+
onClick={() => setTheme((t) => (t === "dark" ? "light" : "dark"))}
282+
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
283+
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
284+
>
285+
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
286+
</button>
287+
</div>
263288
</div>
264289
</header>
265290

@@ -275,7 +300,7 @@ export default function App() {
275300
value={scanSeason}
276301
onChange={(e) => setScanSeason(e.target.value)}
277302
disabled={scanButtonDisabled}
278-
style={{ padding: "0.5rem", borderRadius: "4px", border: "1px solid #ccc", fontSize: "0.9rem" }}
303+
style={{ padding: "0.5rem", borderRadius: "4px", border: "1px solid var(--border-strong)", fontSize: "0.9rem", background: "var(--surface)", color: "var(--text)" }}
279304
>
280305
{seasons.map(s => (
281306
<option key={s.name} value={s.name}>{s.name}</option>
@@ -288,7 +313,7 @@ export default function App() {
288313
<button className="button secondary" onClick={() => void handleRefreshClick()} disabled={loading}>
289314
{loading ? "Refreshing..." : "Refresh Data"}
290315
</button>
291-
<p style={{ fontSize: "0.75rem", color: "#9ca3af", margin: "0" }}>
316+
<p style={{ fontSize: "0.75rem", color: "var(--text-subtle)", margin: "0" }}>
292317
Use the top right season selector to switch the active season.
293318
</p>
294319
{scanState !== "idle" && (
@@ -309,7 +334,7 @@ export default function App() {
309334
</div>
310335

311336
{error && (
312-
<div className="card" style={{ border: "1px solid #fecaca", background: "#fef2f2" }}>
337+
<div className="card" style={{ border: "1px solid var(--error-card-border)", background: "var(--error-card-bg)", color: "var(--error-text)" }}>
313338
<strong>Heads up:</strong> {error}
314339
</div>
315340
)}
@@ -361,6 +386,7 @@ export default function App() {
361386
sensors={sensorsPreview}
362387
season={selectedSeason}
363388
externalSelection={downloaderSelection ?? undefined}
389+
theme={theme}
364390
/>
365391
</section>
366392
</div>

server/installer/data-downloader/frontend/src/components/data-download.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface Props {
2020
sensors: string[];
2121
season?: string;
2222
externalSelection?: ExternalSelection;
23+
theme?: "light" | "dark";
2324
}
2425

2526
const INPUT_FORMAT = "yyyy-LL-dd'T'HH:mm";
@@ -61,7 +62,7 @@ const toUtcTooltip = (value: string) => {
6162
return dt.isValid ? `${dt.toFormat("yyyy-LL-dd HH:mm:ss")} UTC` : value;
6263
};
6364

64-
export function DataDownload({ runs, sensors, season, externalSelection }: Props) {
65+
export function DataDownload({ runs, sensors, season, externalSelection, theme = "light" }: Props) {
6566
const [selectedRunKey, setSelectedRunKey] = useState<string>("");
6667
const [selectedRunTimezone, setSelectedRunTimezone] = useState<string | null>(null);
6768
const [selectedSensor, setSelectedSensor] = useState<string>("");
@@ -219,6 +220,11 @@ export function DataDownload({ runs, sensors, season, externalSelection }: Props
219220
}
220221
};
221222

223+
const isDark = theme === "dark";
224+
const chartFont = isDark ? "#e6e8eb" : "#111827";
225+
const chartGrid = isDark ? "#2c313a" : "#e5e7eb";
226+
const chartLine = isDark ? "#60a5fa" : "#2563eb";
227+
222228
const plotData = useMemo(
223229
() =>
224230
series.length === 0
@@ -230,32 +236,36 @@ export function DataDownload({ runs, sensors, season, externalSelection }: Props
230236
customdata: series.map((point) => toUtcTooltip(point.time)),
231237
type: "scatter",
232238
mode: "lines",
233-
line: { color: "#2563eb", width: 2 },
239+
line: { color: chartLine, width: 2 },
234240
hovertemplate: "%{y}<br>%{customdata}<extra></extra>",
235241
name: selectedSensor || "Sensor"
236242
}
237243
],
238-
[series, selectedSensor]
244+
[series, selectedSensor, chartLine]
239245
);
240246

241247
const plotLayout = useMemo(
242248
() => ({
243249
autosize: true,
244250
margin: { t: 10, r: 20, b: 40, l: 50, pad: 4 },
245251
hovermode: "x unified",
252+
font: { color: chartFont },
246253
xaxis: {
247254
title: "Time (UTC)",
248255
type: "date",
249-
tickformat: "%H:%M\n%b %d"
256+
tickformat: "%H:%M\n%b %d",
257+
gridcolor: chartGrid,
258+
zerolinecolor: chartGrid
250259
},
251260
yaxis: {
252261
title: selectedSensor || "Value",
253-
zeroline: false
262+
zeroline: false,
263+
gridcolor: chartGrid
254264
},
255265
paper_bgcolor: "rgba(0,0,0,0)",
256266
plot_bgcolor: "rgba(0,0,0,0)"
257267
}),
258-
[selectedSensor]
268+
[selectedSensor, chartFont, chartGrid]
259269
);
260270

261271
const plotConfig = useMemo(

0 commit comments

Comments
 (0)