Publish LumaOps source
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user