Web frontend
This commit is contained in:
167
web/src/components/ParentPanel.tsx
Normal file
167
web/src/components/ParentPanel.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/** Parent mode: reachable only via `?parentMode=1`, never from the child-facing UI.
|
||||
*
|
||||
* This hides the settings, it does not protect them - there is no login, and the
|
||||
* endpoints behind it are as open as the rest of the API. That matches the device's
|
||||
* threat model (a box on a home network) and is stated in the README rather than
|
||||
* implied.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Settings } from "../api/types";
|
||||
|
||||
const FIELDS: Array<[keyof Settings, string, string, number, number, number]> = [
|
||||
["min_volume", "Minimale Lautstärke", "Untergrenze am Gerät (0-200)", 0, 200, 1],
|
||||
["max_volume", "Maximale Lautstärke", "Was 100 % in der Kinder-Ansicht bedeutet", 0, 200, 1],
|
||||
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
|
||||
["volume_increment", "Schritt am Drehknopf", "Pro Rasterung", 1, 100, 1],
|
||||
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
|
||||
];
|
||||
|
||||
export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.settings()
|
||||
.then(setSettings)
|
||||
.catch((cause: unknown) => setStatus(String(cause)));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
if (!settings) return;
|
||||
setBusy(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
setSettings(await api.saveSettings(settings));
|
||||
setStatus("Gespeichert.");
|
||||
} catch {
|
||||
// The backend rejects an inverted range or an out-of-band start volume.
|
||||
setStatus("Nicht gespeichert — bitte die Werte prüfen (min ≤ Start ≤ max).");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rescan = async () => {
|
||||
setStatus("Bibliothek wird eingelesen …");
|
||||
await api.refreshLibrary();
|
||||
setStatus("Einlesen gestartet. Neue Titel erscheinen gleich von selbst.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 210 / .75)" }}>
|
||||
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||
🔧 Eltern-Einstellungen
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
color: "var(--ink)",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings === null ? (
|
||||
<div style={{ marginTop: 18, color: "oklch(45% 0.03 210)" }}>Wird geladen …</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
|
||||
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
|
||||
{hint}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={settings[key]}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, [key]: Number(event.target.value) })
|
||||
}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
border: "2px solid oklch(85% 0.02 210)",
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
|
||||
<button
|
||||
onClick={() => void save()}
|
||||
disabled={busy}
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void rescan()}
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 210)",
|
||||
color: "var(--ink)",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
Bibliothek neu einlesen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 210)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Speichern schreibt in die <code>config.yml</code> auf dem Gerät. Die neue
|
||||
Obergrenze gilt sofort, ohne Neustart.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div style={{ marginTop: 14, fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user