Publish LumaOps source

This commit is contained in:
LumaOps release export
2026-09-03 01:18:36 +02:00
commit 7f1c0e5f71
2363 changed files with 501543 additions and 0 deletions
@@ -0,0 +1,116 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ArchiveRestore,
Blocks,
Cable,
ChevronLeft,
CircleGauge,
ClipboardList,
Compass,
Cpu,
FlaskConical,
Info,
Lightbulb,
Menu,
Moon,
Network,
Orbit,
Settings,
Sun,
WandSparkles,
X,
} from "lucide-react";
import { useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { api } from "../api/client";
import { useRealtimeUpdates } from "../api/hooks";
import type { SystemStatus } from "../api/types";
import { useI18n } from "../lib/i18n";
import { useTheme } from "../lib/theme";
import { cn } from "../lib/utils";
import { Badge, IconButton, StatusBadge } from "./ui";
const navigation = [
{ to: "/", key: "overview", icon: CircleGauge },
{ to: "/devices", key: "devices", icon: Cpu },
{ to: "/spaces", key: "spaces", icon: Blocks },
{ to: "/scenes", key: "scenes", icon: WandSparkles },
{ to: "/automations", key: "automations", icon: Orbit },
{ to: "/network", key: "network", icon: Network },
{ to: "/connectors", key: "connectors", icon: Cable },
{ to: "/discovery", key: "discovery", icon: Compass },
] as const;
const systemNavigation = [
{ to: "/activity", key: "activity", icon: Activity },
{ to: "/audit", key: "audit", icon: ClipboardList },
{ to: "/diagnostics", key: "diagnostics", icon: FlaskConical },
{ to: "/backups", key: "backups", icon: ArchiveRestore },
{ to: "/settings", key: "settings", icon: Settings },
{ to: "/about", key: "about", icon: Info },
] as const;
export function AppShell() {
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const { t } = useI18n();
const { resolved, setTheme } = useTheme();
const system = useQuery({
queryKey: ["system"],
queryFn: () => api<SystemStatus>("/api/v1/system"),
refetchInterval: 30_000,
});
useRealtimeUpdates();
const nav = (items: typeof navigation | typeof systemNavigation) => items.map(({ to, key, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) => cn("nav-link", isActive && "nav-link--active")}
onClick={() => setOpen(false)}
title={collapsed ? t(key) : undefined}
>
<Icon size={18} aria-hidden />
<span>{t(key)}</span>
</NavLink>
));
return (
<div className={cn("app-shell", collapsed && "app-shell--collapsed")}>
<aside className={cn("sidebar", open && "sidebar--open")}>
<div className="brand">
<span className="brand__mark"><Lightbulb size={20} aria-hidden /></span>
<div className="brand__text"><strong>LumaOps</strong><span>OpenRGB control plane</span></div>
<IconButton className="sidebar__close" label={t("closeMenu")} onClick={() => setOpen(false)}><X size={18} /></IconButton>
</div>
<nav aria-label="Hoofdnavigatie" className="sidebar__nav">
<span className="nav-label">Beheer</span>
{nav(navigation)}
<span className="nav-label">Systeem</span>
{nav(systemNavigation)}
</nav>
<button className="sidebar__collapse" onClick={() => setCollapsed((value) => !value)}>
<ChevronLeft size={16} aria-hidden /><span>Navigatie inklappen</span>
</button>
</aside>
{open ? <button className="sidebar-backdrop" aria-label={t("closeMenu")} onClick={() => setOpen(false)} /> : null}
<div className="app-main">
<header className="topbar">
<IconButton className="mobile-menu" label={t("openMenu")} onClick={() => setOpen(true)}><Menu size={20} /></IconButton>
<div className="topbar__status">
{system.data ? <StatusBadge status={system.data.health.status} /> : <Badge>Laden…</Badge>}
<span className="topbar__device-count">{system.data?.devices.online ?? 0} apparaten online</span>
</div>
<IconButton label={resolved === "dark" ? "Licht thema" : "Donker thema"} onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}>
{resolved === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</IconButton>
</header>
{system.data?.mock_mode ? <div className="mock-banner"><FlaskConical size={16} /> {t("mockWarning")}</div> : null}
<main className="page"><Outlet /></main>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { Save, X } from "lucide-react";
import { useState, type FormEvent } from "react";
import type { Automation, Page, Scene } from "../api/types";
import { useI18n } from "../lib/i18n";
import { formValue } from "../lib/utils";
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
export interface AutomationPayload {
name: string;
description: string | null;
enabled: boolean;
trigger: { type: "time"; at: string; weekdays: number[] };
actions: Array<{ type: "scene"; scene_id: string }>;
timezone: string;
cooldown_seconds: number;
conflict_key: string | null;
}
interface AutomationEditorProps {
automation?: Automation;
scenes?: Page<Scene>;
busy: boolean;
onSubmit: (payload: AutomationPayload) => void;
onClose: () => void;
}
const weekdays = [
[0, "Ma", "Mon"],
[1, "Di", "Tue"],
[2, "Wo", "Wed"],
[3, "Do", "Thu"],
[4, "Vr", "Fri"],
[5, "Za", "Sat"],
[6, "Zo", "Sun"],
] as const;
export function AutomationEditor({ automation, scenes, busy, onSubmit, onClose }: AutomationEditorProps) {
const { text } = useI18n();
const selectedWeekdays = Array.isArray(automation?.trigger.weekdays) ? automation.trigger.weekdays.map(Number) : [0, 1, 2, 3, 4, 5, 6];
const [selectedDays, setSelectedDays] = useState(selectedWeekdays);
const sceneId = typeof automation?.actions[0]?.scene_id === "string" ? automation.actions[0].scene_id : "";
const at = typeof automation?.trigger.at === "string" ? automation.trigger.at : "20:00";
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
onSubmit({
name: formValue(data, "name").trim(),
description: formValue(data, "description").trim() || null,
enabled: data.get("enabled") === "on",
trigger: { type: "time", at: formValue(data, "at"), weekdays: selectedDays },
actions: [{ type: "scene", scene_id: formValue(data, "scene") }],
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
cooldown_seconds: Number(data.get("cooldown")),
conflict_key: formValue(data, "conflict_key").trim() || null,
});
};
return (
<Card className="resource-editor">
<CardHeader title={automation ? text("Automation bewerken", "Edit automation") : text("Automation aanmaken", "Create automation")} description={text("Plan een scène op geselecteerde weekdagen en voorkom conflicterende uitvoeringen.", "Schedule a scene on selected weekdays and prevent conflicting runs.")} action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>} />
<form onSubmit={submit} className="form-stack">
<div className="form-grid form-grid--two"><Field label={text("Naam", "Name")}><Input name="name" required autoFocus defaultValue={automation?.name ?? ""} placeholder={text("Avondverlichting", "Evening lights")} /></Field><Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={automation?.description ?? ""} /></Field></div>
<div className="form-grid form-grid--three"><Field label={text("Tijdstip", "Time")}><Input name="at" type="time" required defaultValue={at} /></Field><Field label={text("Scène", "Scene")}><Select name="scene" required defaultValue={sceneId}><option value="">{text("Selecteer een scène", "Select a scene")}</option>{scenes?.items.map((scene) => <option key={scene.id} value={scene.id}>{scene.name}</option>)}</Select></Field><Field label={text("Cooldown (seconden)", "Cooldown (seconds)")}><Input name="cooldown" type="number" min="0" max="604800" defaultValue={automation?.cooldown_seconds ?? 60} /></Field></div>
<fieldset className="weekday-grid"><legend>{text("Weekdagen", "Weekdays")}</legend>{weekdays.map(([value, dutch, english]) => <label key={value}><input type="checkbox" name="weekdays" value={value} checked={selectedDays.includes(value)} onChange={(event) => setSelectedDays((current) => event.target.checked ? [...current, value].sort() : current.filter((day) => day !== value))} /> <span>{text(dutch, english)}</span></label>)}</fieldset>
{!selectedDays.length ? <p className="field-error" role="alert">{text("Selecteer minstens één weekdag.", "Select at least one weekday.")}</p> : null}
<Field label={text("Conflictgroep (optioneel)", "Conflict group (optional)")} hint={text("Regels met dezelfde sleutel worden nooit gelijktijdig uitgevoerd.", "Rules with the same key never run concurrently.")}><Input name="conflict_key" defaultValue={automation?.conflict_key ?? ""} placeholder="woonkamer" /></Field>
<label className="checkbox-row"><input name="enabled" type="checkbox" defaultChecked={automation?.enabled ?? true} /> {text("Automation is actief", "Automation is enabled")}</label>
<div className="form-actions"><Button variant="ghost" type="button" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={busy} disabled={!selectedDays.length}><Save size={16} /> {automation ? text("Wijzigingen opslaan", "Save changes") : text("Aanmaken", "Create")}</Button></div>
</form>
</Card>
);
}
@@ -0,0 +1,44 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
import { Button } from "./ui";
export function ConfirmDialog({
open,
title,
description,
confirmLabel,
danger = false,
busy = false,
confirmDisabled = false,
onConfirm,
onClose,
children,
}: {
open: boolean;
title: string;
description: string;
confirmLabel: string;
danger?: boolean;
busy?: boolean;
confirmDisabled?: boolean;
onConfirm: () => void;
onClose: () => void;
children?: ReactNode;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
if (open && !dialog.current?.open) dialog.current?.showModal();
if (!open && dialog.current?.open) dialog.current.close();
}, [open]);
return (
<dialog ref={dialog} className="dialog" aria-labelledby={titleId} onCancel={onClose} onClose={onClose}>
<h2 id={titleId}>{title}</h2>
<p>{description}</p>
{children}
<div className="dialog__actions">
<Button variant="ghost" onClick={onClose}>Annuleren</Button>
<Button variant={danger ? "danger" : "primary"} onClick={onConfirm} busy={busy} disabled={confirmDisabled}>{confirmLabel}</Button>
</div>
</dialog>
);
}
@@ -0,0 +1,27 @@
import { Cpu, MemoryStick, Star, WifiOff } from "lucide-react";
import type { CSSProperties } from "react";
import { Link } from "react-router-dom";
import type { Device } from "../api/types";
import { colorToHex, formatDate } from "../lib/utils";
import { Badge } from "./ui";
interface DeviceCardProps {
device: Device;
list?: boolean;
moduleLabel?: string;
}
export function DeviceCard({ device, list = false, moduleLabel }: DeviceCardProps) {
const color = colorToHex(device.state.colors?.[0]);
const Icon = device.device_type === "dram" ? MemoryStick : Cpu;
return <Link to={`/devices/${device.id}`} className={list ? "device-row" : "device-card"}>
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
<span className="device-glow" /><Icon size={list ? 22 : 30} /><span className={`presence ${device.online ? "presence--online" : ""}`} />
</div>
<div className="device-card__content">
<div className="device-card__title"><div><h2>{moduleLabel || device.alias || device.name}</h2><p>{[device.vendor, device.model].filter(Boolean).join(" · ") || device.source}</p></div>{device.favorite ? <Star size={16} className="favorite" fill="currentColor" /> : null}</div>
<div className="device-card__meta"><Badge tone={device.online ? "success" : "neutral"}>{device.online ? "Online" : <><WifiOff size={12} /> Offline</>}</Badge>{device.room_name ? <Badge>{device.room_name}</Badge> : null}{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}{device.blocked ? <Badge tone="danger">Geblokkeerd</Badge> : null}</div>
{!list ? <div className="device-card__footer"><span><i style={{ background: color }} />{device.state.mode ?? "Onbekende modus"}</span><span>{device.led_count} leds</span></div> : <div className="device-row__extra"><span>{device.state.brightness ?? "—"}%</span><span>{formatDate(device.last_detected_at)}</span></div>}
</div>
</Link>;
}
@@ -0,0 +1,25 @@
import { MemoryStick } from "lucide-react";
import type { CSSProperties } from "react";
import { Link } from "react-router-dom";
import type { DeviceGroup } from "../api/types";
import { colorToHex } from "../lib/utils";
import { Badge } from "./ui";
interface DeviceGroupCardProps {
group: DeviceGroup;
list?: boolean;
}
export function DeviceGroupCard({ group, list = false }: DeviceGroupCardProps) {
const color = colorToHex(group.state.colors?.[0]);
return <Link to={`/device-groups/${group.id}`} className={`${list ? "device-row" : "device-card"} device-card--group`}>
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
<span className="device-glow" /><MemoryStick size={list ? 22 : 30} /><span className={`presence ${group.online_count ? "presence--online" : ""}`} />
</div>
<div className="device-card__content">
<div className="device-card__title"><div><h2>{group.name}</h2><p>{group.vendor} · gegroepeerd RAM-apparaat</p></div><Badge tone="accent">Groep</Badge></div>
<div className="device-card__meta"><Badge tone={group.online ? "success" : "warning"}>{group.online_count}/{group.module_count} online</Badge>{group.mixed ? <Badge tone="warning">Gemengde toestand</Badge> : null}</div>
<div className={list ? "device-row__extra" : "device-card__footer"}><span>{group.state.mode ?? "Gemengd"}</span><span>{group.module_count} modules · {group.led_count} leds</span></div>
</div>
</Link>;
}
@@ -0,0 +1,165 @@
import { Power } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { Capabilities, DeviceMode, DeviceState } from "../api/types";
import { hexToColor, colorToHex } from "../lib/utils";
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
const DIRECTION_FLAGS = 2 | 4 | 8;
interface RgbControlPanelProps {
targetKey: string;
capabilities: Capabilities;
state: DeviceState;
modes: DeviceMode[];
pending: boolean;
disabled: boolean;
onApply: (state: DeviceState) => void;
}
export function RgbControlPanel({
targetKey,
capabilities,
state,
modes,
pending,
disabled,
onApply,
}: RgbControlPanelProps) {
const defaultColorMode = findColorMode(modes);
const [modeIndex, setModeIndex] = useState<number | undefined>(
state.mode_index ?? defaultColorMode?.index,
);
const [colors, setColors] = useState<string[]>(() => stateColors(state, modes));
const [brightness, setBrightness] = useState(state.brightness ?? 100);
const [speed, setSpeed] = useState(state.speed ?? 0);
const [direction, setDirection] = useState(state.direction ?? 0);
const synchronizedTarget = useRef("");
const dirty = useRef(false);
useEffect(() => {
const changedTarget = synchronizedTarget.current !== targetKey;
if (!changedTarget && dirty.current) return;
synchronizedTarget.current = targetKey;
dirty.current = false;
const nextModeIndex = state.mode_index ?? findColorMode(modes)?.index;
setModeIndex(nextModeIndex);
setColors(stateColors({ ...state, mode_index: nextModeIndex }, modes));
setBrightness(state.brightness ?? 100);
setSpeed(state.speed ?? modes.find((mode) => mode.index === nextModeIndex)?.speed_min ?? 0);
setDirection(state.direction ?? 0);
}, [modes, state, targetKey]);
const selectedMode = modes.find((mode) => mode.index === modeIndex);
const colorSlots = modeColorSlots(selectedMode, capabilities.rgb);
const applyColorSlots = colorSlots;
const supportsDirection = Boolean((selectedMode?.flags ?? 0) & DIRECTION_FLAGS);
const supportsSpeed = selectedMode?.speed_min != null && selectedMode.speed_max != null;
const selectMode = (nextIndex: number) => {
dirty.current = true;
const nextMode = modes.find((mode) => mode.index === nextIndex);
setModeIndex(nextIndex);
setColors(resizeColors(colors, modeColorSlots(nextMode, capabilities.rgb)));
setSpeed(clamp(state.speed ?? nextMode?.speed_min ?? 0, nextMode?.speed_min, nextMode?.speed_max));
setBrightness(state.brightness ?? 100);
setDirection(state.direction ?? 0);
};
const updateColor = (index: number, value: string) => {
dirty.current = true;
const next = resizeColors(colors, Math.max(colorSlots, index + 1));
next[index] = value;
setColors(next);
if (selectedMode?.name.toLocaleLowerCase() === "direct" && defaultColorMode) {
setModeIndex(defaultColorMode.index);
}
};
const apply = () => {
const command: DeviceState = { power: true };
if (modeIndex != null) command.mode_index = modeIndex;
if (applyColorSlots) command.colors = colors.slice(0, applyColorSlots).map(hexToColor);
if (selectedMode?.brightness) command.brightness = brightness;
if (supportsSpeed) command.speed = speed;
if (supportsDirection) command.direction = direction;
dirty.current = false;
onApply(command);
};
return <Card className="control-panel">
<CardHeader title="Bediening" description="Elke modus gebruikt uitsluitend de parameters die de hardware ondersteunt." />
<div className="power-actions">
<Button onClick={() => onApply({ power: true })} disabled={!capabilities.power || disabled}>
<Power size={16} /> Aan
</Button>
<Button variant="secondary" onClick={() => onApply({ power: false })} disabled={!capabilities.power || disabled}>
Uit
</Button>
</div>
{capabilities.effect && modes.length ? <Field label="Effectmodus">
<Select aria-label="Effectmodus" value={modeIndex ?? ""} onChange={(event) => selectMode(Number(event.target.value))}>
{modes.map((mode) => <option key={mode.index} value={mode.index}>{mode.name}</option>)}
</Select>
</Field> : null}
{colorSlots ? <Field label={colorSlots > 1 ? "Effectkleuren" : "Statische kleur"}>
<div className="effect-colors">
{resizeColors(colors, colorSlots).map((color, index) => <div className="effect-color" key={`${targetKey}-color-${index}`}>
{colorSlots > 1 ? <span>Kleur {index + 1}</span> : null}
<div className="large-color">
<input aria-label={`Kleur ${index + 1} kiezen`} type="color" value={validColor(color)} onChange={(event) => updateColor(index, event.target.value)} />
<Input aria-label={`Kleur ${index + 1}`} value={color.toUpperCase()} onChange={(event) => updateColor(index, event.target.value)} />
</div>
</div>)}
</div>
</Field> : <p className="mode-hint">Deze hardwaremodus genereert zijn kleuren automatisch.</p>}
{selectedMode?.brightness ? <Field label={`Helderheid · ${brightness}%`}>
<input aria-label="Helderheid" className="range" type="range" min="0" max="100" value={brightness} onChange={(event) => { dirty.current = true; setBrightness(Number(event.target.value)); }} />
</Field> : null}
{supportsSpeed ? <Field label={`Snelheid · ${speed}`}>
<input aria-label="Snelheid" className="range" type="range" min={selectedMode.speed_min ?? 0} max={selectedMode.speed_max ?? 0} value={speed} onChange={(event) => { dirty.current = true; setSpeed(Number(event.target.value)); }} />
</Field> : null}
{supportsDirection ? <Field label="Richting">
<Select aria-label="Richting" value={direction} onChange={(event) => { dirty.current = true; setDirection(Number(event.target.value)); }}>
<option value={0}>Vooruit</option>
<option value={1}>Achteruit</option>
</Select>
</Field> : null}
<Button className="full-width" onClick={apply} busy={pending} disabled={disabled}>
Instellingen toepassen
</Button>
</Card>;
}
function findColorMode(modes: DeviceMode[]) {
return modes.find((mode) => mode.name.toLocaleLowerCase() === "custom")
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "static")
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "direct");
}
function modeColorSlots(mode: DeviceMode | undefined, rgb: boolean) {
if (!rgb) return 0;
if (!mode) return 1;
if (mode.colors_min > 0) return mode.colors_min;
return ["direct", "custom"].includes(mode.name.toLocaleLowerCase()) ? 1 : 0;
}
function stateColors(state: DeviceState, modes: DeviceMode[]) {
const mode = modes.find((candidate) => candidate.index === state.mode_index);
const count = modeColorSlots(mode, true);
const values = (state.colors ?? []).map(colorToHex);
return resizeColors(values.length ? values : ["#5660ff"], count || 1);
}
function resizeColors(colors: string[], count: number) {
if (!count) return [];
const seed = colors[0] ?? "#5660ff";
return Array.from({ length: count }, (_, index) => colors[index] ?? seed);
}
function validColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
}
function clamp(value: number, minimum: number | null | undefined, maximum: number | null | undefined) {
return Math.max(minimum ?? value, Math.min(maximum ?? value, value));
}
@@ -0,0 +1,116 @@
import { Fan } from "lucide-react";
import { useEffect, useState } from "react";
import type { DeviceState, DeviceZone, RGBColor } from "../api/types";
import { colorToHex, hexToColor } from "../lib/utils";
import { Badge, Button, Card, Field, Input, Select } from "./ui";
type ZonePattern = "static" | "rainbow" | "alternating" | "off";
interface ZoneDraft {
pattern: ZonePattern;
primary: string;
secondary: string;
}
interface RgbZoneControlsProps {
targetKey: string;
zones: DeviceZone[];
state: DeviceState;
pendingZone?: number;
disabled: boolean;
onApply: (zoneIndex: number, state: DeviceState) => void;
}
export function RgbZoneControls({
targetKey,
zones,
state,
pendingZone,
disabled,
onApply,
}: RgbZoneControlsProps) {
const [drafts, setDrafts] = useState<Record<number, ZoneDraft>>({});
useEffect(() => {
setDrafts(Object.fromEntries(zones.map((zone) => [zone.index, {
pattern: "static",
primary: zoneColor(state, zone),
secondary: "#00c2a8",
}])));
}, [state, targetKey, zones]);
const update = (zoneIndex: number, patch: Partial<ZoneDraft>) => {
setDrafts((current) => ({
...current,
[zoneIndex]: {
...(current[zoneIndex] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" }),
...patch,
},
}));
};
return <div className="argb-zone-list">
{zones.map((zone) => {
const draft = drafts[zone.index] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" };
const automaticLedCount = zone.leds_min === zone.leds_max ? zone.led_count : zone.leds_max;
const colors = patternColors(draft, automaticLedCount);
return <Card className="argb-zone-card" data-testid={`argb-zone-${zone.index}`} key={zone.index}>
<div className="argb-zone-card__heading">
<span><Fan size={19} /></span>
<div><h3>{zone.name}</h3><p>Individuele Aura-zone</p></div>
<Badge tone="success">Alle {automaticLedCount} leds</Badge>
</div>
<Field label={`Modus voor ${zone.name}`}>
<Select aria-label={`Modus voor ${zone.name}`} value={draft.pattern} onChange={(event) => update(zone.index, { pattern: event.target.value as ZonePattern })}>
<option value="static">Vaste kleur</option>
<option value="rainbow">Regenboogpatroon</option>
<option value="alternating">Afwisselende kleuren</option>
<option value="off">Uit</option>
</Select>
</Field>
{draft.pattern !== "off" && draft.pattern !== "rainbow" ? <Field label="Hoofdkleur">
<div className="large-color">
<input aria-label={`Hoofdkleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.primary)} onChange={(event) => update(zone.index, { primary: event.target.value })} />
<Input aria-label={`Hoofdkleur voor ${zone.name}`} value={draft.primary.toUpperCase()} onChange={(event) => update(zone.index, { primary: event.target.value })} />
</div>
</Field> : null}
{draft.pattern === "alternating" ? <Field label="Tweede kleur">
<div className="large-color">
<input aria-label={`Tweede kleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.secondary)} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
<Input aria-label={`Tweede kleur voor ${zone.name}`} value={draft.secondary.toUpperCase()} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
</div>
</Field> : null}
<p className="mode-hint">De volledige header wordt automatisch gebruikt; een LED-aantal ingeven is niet nodig.</p>
<Button className="full-width" busy={pendingZone === zone.index} disabled={disabled || pendingZone != null} onClick={() => onApply(zone.index, { power: true, colors })}>
Alleen {zone.name} toepassen
</Button>
</Card>;
})}
</div>;
}
function zoneColor(state: DeviceState, zone: DeviceZone) {
return colorToHex(state.colors?.[zone.start_index ?? 0] ?? state.colors?.[0]);
}
function patternColors(draft: ZoneDraft, count: number): RGBColor[] {
if (draft.pattern === "off") return [{ red: 0, green: 0, blue: 0 }];
if (draft.pattern === "static") return [hexToColor(validColor(draft.primary))];
if (draft.pattern === "alternating") {
const colors = [hexToColor(validColor(draft.primary)), hexToColor(validColor(draft.secondary))];
return Array.from({ length: count }, (_, index) => colors[index % colors.length]!);
}
return Array.from({ length: count }, (_, index) => hslToRgb(index / Math.max(1, count)));
}
function hslToRgb(hue: number): RGBColor {
const channel = (offset: number) => {
const value = (offset + hue * 6) % 6;
return Math.round(255 * (1 - Math.max(0, Math.min(value, 4 - value, 1))));
};
return { red: channel(5), green: channel(3), blue: channel(1) };
}
function validColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
}
@@ -0,0 +1,139 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Save, Trash2, X } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import { api } from "../api/client";
import type { Device, DeviceState, Group, Page, Scene, SceneItem } from "../api/types";
import { useI18n } from "../lib/i18n";
import { formValue, hexToColor } from "../lib/utils";
import { useToast } from "./Toast";
import { Button, Card, CardHeader, ErrorPanel, Field, Input, LoadingGrid, Select } from "./ui";
interface SceneEditorProps {
sceneId: string;
onClose: () => void;
}
type EditableItem = Omit<SceneItem, "id"> & { id?: string };
export function SceneEditor({ sceneId, onClose }: SceneEditorProps) {
const { text } = useI18n();
const { notify } = useToast();
const queryClient = useQueryClient();
const scene = useQuery({ queryKey: ["scene", sceneId], queryFn: () => api<Scene>(`/api/v1/scenes/${sceneId}`) });
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const groups = useQuery({ queryKey: ["groups"], queryFn: () => api<Group[]>("/api/v1/groups") });
const [items, setItems] = useState<EditableItem[]>([]);
useEffect(() => {
if (scene.data?.items) setItems(scene.data.items);
}, [scene.data]);
const save = useMutation({
mutationFn: (payload: Record<string, unknown>) => api<Scene>(`/api/v1/scenes/${sceneId}`, { method: "PUT", body: JSON.stringify(payload) }),
onSuccess: () => {
notify(text("Scène opgeslagen.", "Scene saved."));
void queryClient.invalidateQueries({ queryKey: ["scenes"] });
void queryClient.invalidateQueries({ queryKey: ["scene", sceneId] });
onClose();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
if (scene.isLoading || devices.isLoading || groups.isLoading) return <LoadingGrid count={2} />;
if (scene.error || devices.error || groups.error || !scene.data) {
return <ErrorPanel error={scene.error ?? devices.error ?? groups.error} retry={() => void Promise.all([scene.refetch(), devices.refetch(), groups.refetch()])} />;
}
const targetName = (item: EditableItem) => {
if (item.target_type === "device") {
const device = devices.data?.items.find((candidate) => candidate.id === item.target_id);
return device?.alias || device?.name || item.target_id;
}
return groups.data?.find((candidate) => candidate.id === item.target_id)?.name || item.target_id;
};
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
save.mutate({
name: data.get("name"),
description: data.get("description") || null,
favorite: data.get("favorite") === "on",
items: items.map((item, index) => ({ ...item, sort_order: index })),
});
};
const addItem = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const target = formValue(data, "target");
if (!target.includes(":")) return;
const [targetType, targetId] = target.split(":", 2) as ["device" | "group", string];
const state: DeviceState = {};
const power = data.get("power");
if (power === "unchanged" && data.get("include_color") !== "on" && data.get("include_brightness") !== "on") {
notify(text("Selecteer minstens één eigenschap voor dit doel.", "Select at least one property for this target."), "danger");
return;
}
if (power === "on") state.power = true;
if (power === "off") state.power = false;
if (data.get("include_color") === "on") state.colors = [hexToColor(formValue(data, "color"))];
if (data.get("include_brightness") === "on") state.brightness = Number(data.get("brightness"));
setItems((current) => [
...current.filter((item) => !(item.target_type === targetType && item.target_id === targetId)),
{ target_type: targetType, target_id: targetId, state, required: true, sort_order: current.length },
]);
event.currentTarget.reset();
};
return (
<Card className="resource-editor">
<CardHeader
title={text("Scène bewerken", "Edit scene")}
description={text("Stel doelen en alleen de gewenste eigenschappen in. Niet aangevinkte eigenschappen blijven ongemoeid.", "Configure targets and only the desired properties. Unchecked properties remain unchanged.")}
action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>}
/>
<form id="scene-metadata" className="form-stack" onSubmit={submit}>
<div className="form-grid form-grid--two">
<Field label={text("Naam", "Name")}><Input name="name" required defaultValue={scene.data.name} /></Field>
<Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={scene.data.description ?? ""} /></Field>
</div>
<label className="checkbox-row"><input name="favorite" type="checkbox" defaultChecked={scene.data.favorite} /> {text("Toon als favoriete scène", "Show as favorite scene")}</label>
</form>
<div className="resource-editor__section">
<h3>{text("Doeltoestanden", "Target states")}</h3>
{!items.length ? <p className="muted">{text("Deze scène bevat nog geen doelen.", "This scene has no targets yet.")}</p> : (
<div className="scene-item-list">
{items.map((item) => (
<div className="scene-item" key={`${item.target_type}:${item.target_id}`}>
<div><strong>{targetName(item)}</strong><span>{item.target_type === "group" ? text("Groep", "Group") : text("Apparaat", "Device")} · {describeState(item.state, text)}</span></div>
<Button type="button" variant="ghost" title={text("Verwijderen", "Remove")} aria-label={text(`Doel ${targetName(item)} verwijderen`, `Remove target ${targetName(item)}`)} onClick={() => setItems((current) => current.filter((candidate) => candidate !== item))}><Trash2 size={16} /></Button>
</div>
))}
</div>
)}
</div>
<form className="scene-target-form" onSubmit={addItem}>
<Field label={text("Doel", "Target")}>
<Select name="target" required defaultValue="">
<option value="" disabled>{text("Selecteer apparaat of groep", "Select device or group")}</option>
<optgroup label={text("Apparaten", "Devices")}>{devices.data?.items.map((device) => <option key={device.id} value={`device:${device.id}`}>{device.alias || device.name}</option>)}</optgroup>
<optgroup label={text("Groepen", "Groups")}>{groups.data?.map((group) => <option key={group.id} value={`group:${group.id}`}>{group.name}</option>)}</optgroup>
</Select>
</Field>
<Field label={text("Voeding", "Power")}><Select name="power" defaultValue="unchanged"><option value="unchanged">{text("Niet wijzigen", "Unchanged")}</option><option value="on">{text("Aan", "On")}</option><option value="off">{text("Uit", "Off")}</option></Select></Field>
<Field label={text("Kleur", "Color")}><div className="option-control"><input name="include_color" type="checkbox" aria-label={text("Kleur opnemen", "Include color")} /><input name="color" type="color" defaultValue="#5660ff" /></div></Field>
<Field label={text("Helderheid", "Brightness")}><div className="option-control"><input name="include_brightness" type="checkbox" aria-label={text("Helderheid opnemen", "Include brightness")} /><Input name="brightness" type="number" min="0" max="100" defaultValue="100" /></div></Field>
<Button type="submit" variant="secondary"><Plus size={16} /> {text("Doel toevoegen", "Add target")}</Button>
</form>
<div className="resource-editor__actions"><Button type="button" variant="ghost" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" form="scene-metadata" busy={save.isPending}><Save size={16} /> {text("Scène opslaan", "Save scene")}</Button></div>
</Card>
);
}
function describeState(state: DeviceState, text: (dutch: string, english: string) => string): string {
const values: string[] = [];
if (state.power !== undefined && state.power !== null) values.push(state.power ? text("aan", "on") : text("uit", "off"));
const color = state.colors?.[0];
if (color) values.push(`rgb(${color.red}, ${color.green}, ${color.blue})`);
if (state.brightness !== undefined && state.brightness !== null) values.push(`${state.brightness}%`);
return values.join(" · ") || text("geen eigenschappen", "no properties");
}
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { CheckCircle2, X, XCircle } from "lucide-react";
import { IconButton } from "./ui";
interface ToastItem { id: string; message: string; tone: "success" | "danger" }
interface ToastValue { notify: (message: string, tone?: ToastItem["tone"]) => void }
const ToastContext = createContext<ToastValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const dismiss = useCallback((id: string) => setItems((current) => current.filter((item) => item.id !== id)), []);
const value = useMemo<ToastValue>(() => ({ notify: (message, tone = "success") => {
const id = crypto.randomUUID();
setItems((current) => [...current.slice(-3), { id, message, tone }]);
window.setTimeout(() => dismiss(id), 4500);
} }), [dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toasts" aria-live="polite">
{items.map((item) => <div key={item.id} className={`toast toast--${item.tone}`}>
{item.tone === "success" ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
<span>{item.message}</span>
<IconButton label="Sluiten" onClick={() => dismiss(item.id)}><X size={15} /></IconButton>
</div>)}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastValue {
const value = useContext(ToastContext);
if (!value) throw new Error("useToast moet binnen ToastProvider gebruikt worden");
return value;
}
@@ -0,0 +1,21 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "./ui";
import { renderApp } from "../test/render";
import { useI18n } from "../lib/i18n";
function LanguageFixture() {
const { setLanguage } = useI18n();
return <><StatusBadge status="degraded" /><button onClick={() => setLanguage("en")}>English</button></>;
}
describe("local component layer", () => {
it("renders status accessibly and switches language", async () => {
renderApp(<LanguageFixture />);
expect(screen.getByText("Beperkt")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "English" }));
expect(screen.getByText("Degraded")).toBeInTheDocument();
});
});
+209
View File
@@ -0,0 +1,209 @@
import {
AlertTriangle,
CheckCircle2,
CircleHelp,
Info,
LoaderCircle,
RefreshCw,
XCircle,
type LucideIcon,
} from "lucide-react";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
} from "react";
import type { HealthStatus } from "../api/types";
import { useI18n } from "../lib/i18n";
import { cn } from "../lib/utils";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export function Button({
variant = "primary",
className,
children,
busy,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; busy?: boolean }) {
return (
<button className={cn("button", `button--${variant}`, className)} disabled={busy || props.disabled} {...props}>
{busy ? <LoaderCircle className="spin" size={16} aria-hidden /> : null}
{children}
</button>
);
}
export function IconButton({ label, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
return (
<button className="icon-button" aria-label={label} title={label} {...props}>
{children}
</button>
);
}
export function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("card", className)} {...props}>
{children}
</div>
);
}
export function CardHeader({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="card__header">
<div>
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
</div>
{action}
</div>
);
}
export function Badge({
tone = "neutral",
children,
}: {
tone?: "neutral" | "success" | "warning" | "danger" | "accent";
children: ReactNode;
}) {
return <span className={cn("badge", `badge--${tone}`)}>{children}</span>;
}
export function StatusBadge({ status }: { status: HealthStatus }) {
const { t } = useI18n();
const mapping: Record<HealthStatus, { icon: LucideIcon; tone: "success" | "warning" | "danger" | "neutral" }> = {
healthy: { icon: CheckCircle2, tone: "success" },
degraded: { icon: AlertTriangle, tone: "warning" },
unhealthy: { icon: XCircle, tone: "danger" },
unknown: { icon: CircleHelp, tone: "neutral" },
};
const item = mapping[status];
const Icon = item.icon;
return (
<Badge tone={item.tone}>
<Icon size={13} aria-hidden /> {t(status)}
</Badge>
);
}
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn("input", props.className)} {...props} />;
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select className={cn("input", props.className)} {...props} />;
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="field">
<span className="field__label">{label}</span>
{children}
{hint ? <span className="field__hint">{hint}</span> : null}
</label>
);
}
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow?: string;
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
<h1>{title}</h1>
{description ? <p>{description}</p> : null}
</div>
{actions ? <div className="page-header__actions">{actions}</div> : null}
</header>
);
}
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("skeleton", className)} aria-hidden />;
}
export function LoadingGrid({ count = 4 }: { count?: number }) {
return (
<div className="grid grid--cards" aria-label="Laden">
{Array.from({ length: count }, (_, index) => (
<Card key={index} className="skeleton-card">
<Skeleton className="skeleton--short" />
<Skeleton />
<Skeleton className="skeleton--medium" />
</Card>
))}
</div>
);
}
export function EmptyState({
title,
description,
action,
icon: Icon = Info,
}: {
title: string;
description: string;
action?: ReactNode;
icon?: LucideIcon;
}) {
return (
<div className="empty-state">
<span className="empty-state__icon"><Icon size={24} aria-hidden /></span>
<h2>{title}</h2>
<p>{description}</p>
{action}
</div>
);
}
export function ErrorPanel({ error, retry }: { error: unknown; retry?: () => void }) {
const { t } = useI18n();
const message = error instanceof Error ? error.message : "Onbekende fout";
return (
<div className="alert alert--danger" role="alert">
<XCircle size={20} aria-hidden />
<div>
<strong>Deze gegevens konden niet worden geladen.</strong>
<p>{message}</p>
</div>
{retry ? (
<Button variant="secondary" onClick={retry}>
<RefreshCw size={16} aria-hidden /> {t("retry")}
</Button>
) : null}
</div>
);
}
export function Stat({ label, value, detail, icon: Icon }: { label: string; value: ReactNode; detail?: string; icon: LucideIcon }) {
return (
<Card className="stat">
<span className="stat__icon"><Icon size={20} aria-hidden /></span>
<div><span className="stat__label">{label}</span><strong>{value}</strong>{detail ? <small>{detail}</small> : null}</div>
</Card>
);
}