123 lines
7.1 KiB
TypeScript
123 lines
7.1 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { ArrowLeft, Eye, Lightbulb, MapPin, ScanLine, Shield, Star, Tag, Wifi } from "lucide-react";
|
|
import { Link, useParams } from "react-router-dom";
|
|
import { api, mutationId } from "../api/client";
|
|
import type { Device, DeviceState } from "../api/types";
|
|
import { RgbControlPanel } from "../components/RgbControlPanel";
|
|
import { RgbZoneControls } from "../components/RgbZoneControls";
|
|
import { useToast } from "../components/Toast";
|
|
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
|
|
import { useI18n } from "../lib/i18n";
|
|
import { formatDate } from "../lib/utils";
|
|
|
|
export function DeviceDetailPage() {
|
|
const { text } = useI18n();
|
|
const { deviceId = "" } = useParams();
|
|
const queryClient = useQueryClient();
|
|
const { notify } = useToast();
|
|
const query = useQuery({
|
|
queryKey: ["device", deviceId],
|
|
queryFn: () => api<Device>(`/api/v1/devices/${deviceId}`),
|
|
});
|
|
|
|
const command = useMutation({
|
|
mutationFn: (state: DeviceState) => api(`/api/v1/devices/${deviceId}/state`, {
|
|
method: "POST",
|
|
headers: { "Idempotency-Key": mutationId() },
|
|
body: JSON.stringify({ state }),
|
|
}),
|
|
onSuccess: () => {
|
|
notify("Apparaat bijgewerkt.");
|
|
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
|
|
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
|
|
void queryClient.invalidateQueries({ queryKey: ["system"] });
|
|
},
|
|
onError: (error) => notify(error instanceof Error ? error.message : "Opdracht mislukt.", "danger"),
|
|
});
|
|
const patch = useMutation({
|
|
mutationFn: (value: Record<string, unknown>) => api<Device>(`/api/v1/devices/${deviceId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(value),
|
|
}),
|
|
onSuccess: (data) => {
|
|
queryClient.setQueryData(["device", deviceId], data);
|
|
notify("Apparaatinstelling opgeslagen.");
|
|
},
|
|
});
|
|
const identify = useMutation({
|
|
mutationFn: () => api(`/api/v1/devices/${deviceId}/identify`, { method: "POST" }),
|
|
onSuccess: () => notify("Identificatie afgerond."),
|
|
});
|
|
const zoneCommand = useMutation({
|
|
mutationFn: ({ zoneIndex: targetZone, state }: { zoneIndex: number; state: DeviceState }) =>
|
|
api(`/api/v1/devices/${deviceId}/zones/${targetZone}/state`, {
|
|
method: "POST",
|
|
headers: { "Idempotency-Key": mutationId() },
|
|
body: JSON.stringify({ state }),
|
|
}),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
|
|
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
|
|
void queryClient.invalidateQueries({ queryKey: ["system"] });
|
|
notify("Aura-zone bijgewerkt.");
|
|
},
|
|
onError: (error) => notify(error instanceof Error ? error.message : "Aura-zone kon niet worden bijgewerkt.", "danger"),
|
|
});
|
|
|
|
if (query.isLoading) return <LoadingGrid count={3} />;
|
|
if (query.error || !query.data) return <ErrorPanel error={query.error} retry={() => void query.refetch()} />;
|
|
const device = query.data;
|
|
|
|
return <>
|
|
<Link className="back-link" to="/devices"><ArrowLeft size={16} /> Terug naar apparaten</Link>
|
|
<PageHeader
|
|
eyebrow={device.source}
|
|
title={device.alias || device.name}
|
|
description={[device.vendor, device.model, device.location].filter(Boolean).join(" · ")}
|
|
actions={<>
|
|
<Button variant="secondary" onClick={() => identify.mutate()} busy={identify.isPending}><ScanLine size={16} /> {text("Identificeren", "Identify")}</Button>
|
|
<Button variant="ghost" onClick={() => patch.mutate({ favorite: !device.favorite })}><Star size={16} fill={device.favorite ? "currentColor" : "none"} /> {text("Favoriet", "Favorite")}</Button>
|
|
</>}
|
|
/>
|
|
<div className="detail-status">
|
|
<Badge tone={device.online ? "success" : "danger"}><Wifi size={13} /> {device.online ? "Online" : "Offline"}</Badge>
|
|
{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}
|
|
{device.experimental ? <Badge tone="warning">Experimenteel</Badge> : null}
|
|
<span>Laatst gezien {formatDate(device.last_detected_at)}</span>
|
|
</div>
|
|
<div className="detail-grid">
|
|
<RgbControlPanel
|
|
targetKey={device.id}
|
|
capabilities={device.capabilities}
|
|
state={device.state}
|
|
modes={device.modes}
|
|
pending={command.isPending}
|
|
disabled={!device.online || device.read_only || device.blocked}
|
|
onApply={(state) => command.mutate(state)}
|
|
/>
|
|
<div className="detail-side">
|
|
<Card><CardHeader title="Eigenschappen" /><dl className="properties"><div><dt><Shield size={15} /> Beheerder</dt><dd>{device.owner}</dd></div><div><dt><MapPin size={15} /> Locatie</dt><dd>{device.location || "Niet bekend"}</dd></div><div><dt><Lightbulb size={15} /> Leds</dt><dd>{device.led_count}</dd></div><div><dt><Tag size={15} /> Serienummer</dt><dd>{device.serial || "Niet gemeld"}</dd></div></dl></Card>
|
|
<Card><CardHeader title="Beheerbeleid" description="Voorkom onbedoelde globale of wijzigende acties." /><div className="switch-list"><Toggle label="Favoriet" checked={device.favorite} onChange={(value) => patch.mutate({ favorite: value })} /><Toggle label="Verbergen" checked={device.hidden} onChange={(value) => patch.mutate({ hidden: value })} /><Toggle label="Alleen lezen" checked={device.read_only} onChange={(value) => patch.mutate({ read_only: value })} /><Toggle label="Blokkeren" checked={device.blocked} onChange={(value) => patch.mutate({ blocked: value })} danger /></div></Card>
|
|
</div>
|
|
</div>
|
|
|
|
{device.capabilities.per_zone && device.zones.length ? <section className="section-block">
|
|
<div className="section-heading"><div><h2>Moederbord en Aura-addressables</h2><p>Bedien elke zone afzonderlijk. LumaOps gebruikt automatisch de volledige header; hardware-effecten in de algemene bediening blijven controllerbreed.</p></div></div>
|
|
<RgbZoneControls
|
|
targetKey={device.id}
|
|
zones={device.zones}
|
|
state={device.state}
|
|
pendingZone={zoneCommand.isPending ? zoneCommand.variables?.zoneIndex : undefined}
|
|
disabled={!device.online || device.read_only || device.blocked}
|
|
onApply={(zoneIndex, state) => zoneCommand.mutate({ zoneIndex, state })}
|
|
/>
|
|
</section> : null}
|
|
|
|
<section className="section-block"><div className="section-heading"><div><h2>Zones en capabilities</h2><p>OpenRGB SDK-v5 inventory zonder write-side effects.</p></div></div><div className="grid grid--cards">{device.zones.map((zone) => <Card key={zone.index}><div className="zone-card"><span><Eye size={18} /></span><div><h3>{zone.name}</h3><p>{zone.led_count} leds · type {zone.type}</p></div></div></Card>)}{!device.zones.length ? <Card><p className="muted">Dit apparaat rapporteert geen zones.</p></Card> : null}</div></section>
|
|
</>;
|
|
}
|
|
|
|
function Toggle({ label, checked, onChange, danger = false }: { label: string; checked: boolean; onChange: (value: boolean) => void; danger?: boolean }) {
|
|
return <label className={`switch-row ${danger ? "switch-row--danger" : ""}`}><span>{label}</span><input type="checkbox" role="switch" checked={checked} onChange={(event) => onChange(event.target.checked)} /><i /></label>;
|
|
}
|