Update docs and screenshots
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TuxDMX</title>
|
||||
<style>
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: #080b12;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type AppShellProps = {
|
||||
currentPage: string;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
icon: SectionIconName;
|
||||
tone?: "accent" | "muted";
|
||||
items: Array<{ id: string; label: string; icon: IconName }>;
|
||||
}>;
|
||||
onNavigate: (page: string) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type IconName =
|
||||
| "dashboard"
|
||||
| "live"
|
||||
| "scenes"
|
||||
| "effects"
|
||||
| "bpm"
|
||||
| "fixtures"
|
||||
| "patch"
|
||||
| "placement"
|
||||
| "mixitup"
|
||||
| "midi"
|
||||
| "monitor"
|
||||
| "telemetry"
|
||||
| "backup"
|
||||
| "settings";
|
||||
|
||||
type SectionIconName = "overview" | "live-control" | "lighting-setup" | "integrations" | "monitoring" | "system";
|
||||
|
||||
function NavIcon({ name }: { name: IconName }) {
|
||||
const commonProps = {
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
"aria-hidden": true,
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "dashboard":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4 5h7v6H4zM13 5h7v10h-7zM4 13h7v6H4zM13 17h7v2h-7z" />
|
||||
</svg>
|
||||
);
|
||||
case "live":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4 16a8 8 0 0 1 16 0" />
|
||||
<path d="M7 16a5 5 0 0 1 10 0" />
|
||||
<path d="M10.5 16a1.5 1.5 0 0 1 3 0" />
|
||||
<circle cx="12" cy="16" r="1.2" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
case "scenes":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4 7h16M4 12h16M4 17h10" />
|
||||
<circle cx="18" cy="17" r="2" />
|
||||
</svg>
|
||||
);
|
||||
case "effects":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M13 2 5 14h6l-1 8 8-12h-6z" />
|
||||
</svg>
|
||||
);
|
||||
case "bpm":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M5 12h2l2-4 4 8 2-4h4" />
|
||||
</svg>
|
||||
);
|
||||
case "fixtures":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M8 4h8v4H8zM6 8h12v10H6zM10 12h4M10 16h4" />
|
||||
</svg>
|
||||
);
|
||||
case "patch":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M7 7h4v4H7zM13 13h4v4h-4z" />
|
||||
<path d="M11 9h2m-6 6h2m4-4 2-2m-6 6 2-2" />
|
||||
</svg>
|
||||
);
|
||||
case "placement":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M12 21s6-5.2 6-11a6 6 0 1 0-12 0c0 5.8 6 11 6 11Z" />
|
||||
<circle cx="12" cy="10" r="2" />
|
||||
</svg>
|
||||
);
|
||||
case "mixitup":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4 8h8M4 16h12M16 8h4M10 16h2" />
|
||||
<circle cx="14" cy="8" r="2" />
|
||||
<circle cx="8" cy="16" r="2" />
|
||||
</svg>
|
||||
);
|
||||
case "midi":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M6 5h8v12a3 3 0 1 1-3-3h1" />
|
||||
<path d="M14 8h4v8a3 3 0 1 1-3-3h1" />
|
||||
</svg>
|
||||
);
|
||||
case "monitor":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<rect x="3" y="5" width="18" height="12" rx="2" />
|
||||
<path d="M8 20h8M10 17v3M14 17v3" />
|
||||
</svg>
|
||||
);
|
||||
case "telemetry":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M5 18V9M10 18V5M15 18v-7M20 18v-3" />
|
||||
</svg>
|
||||
);
|
||||
case "backup":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M5 8h14v10H5z" />
|
||||
<path d="M9 8V5h6v3M12 12v3M10.5 13.5 12 15l1.5-1.5" />
|
||||
</svg>
|
||||
);
|
||||
case "settings":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.5-2.3.8a7 7 0 0 0-1.7-1L14.5 3h-5l-.4 2.8a7 7 0 0 0-1.7 1L5 6 3 9.5 5 11a7 7 0 0 0 0 2l-2 1.5L5 18l2.4-.8a7 7 0 0 0 1.7 1l.4 2.8h5l.4-2.8a7 7 0 0 0 1.7-1l2.3.8 2-3.5L18.9 13c.1-.3.1-.7.1-1Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SectionIcon({ name }: { name: SectionIconName }) {
|
||||
const commonProps = {
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
"aria-hidden": true,
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "overview":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4 5h7v6H4zM13 5h7v6h-7zM4 13h7v6H4zM13 13h7v6h-7z" />
|
||||
</svg>
|
||||
);
|
||||
case "live-control":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4 16a8 8 0 0 1 16 0" />
|
||||
<path d="M7 16a5 5 0 0 1 10 0" />
|
||||
<path d="M10.5 16a1.5 1.5 0 0 1 3 0" />
|
||||
<circle cx="12" cy="16" r="1.2" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
case "lighting-setup":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M8 4h8v4H8zM6 8h12v10H6zM10 12h4M10 16h4" />
|
||||
</svg>
|
||||
);
|
||||
case "integrations":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M8 8h3v3H8zM13 13h3v3h-3z" />
|
||||
<path d="m11 9 2-2m-4 8 2-2m2 0 2 2m-6-6 2 2" />
|
||||
</svg>
|
||||
);
|
||||
case "monitoring":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M5 18V9M10 18V5M15 18v-7M20 18v-3" />
|
||||
</svg>
|
||||
);
|
||||
case "system":
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.5-2.3.8a7 7 0 0 0-1.7-1L14.5 3h-5l-.4 2.8a7 7 0 0 0-1.7 1L5 6 3 9.5 5 11a7 7 0 0 0 0 2l-2 1.5L5 18l2.4-.8a7 7 0 0 0 1.7 1l.4 2.8h5l.4-2.8a7 7 0 0 0 1.7-1l2.3.8 2-3.5L18.9 13c.1-.3.1-.7.1-1Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function AppShell({ currentPage, sections, onNavigate, children }: AppShellProps) {
|
||||
const activeSectionId = useMemo(
|
||||
() => sections.find((section) => section.items.some((item) => item.id === currentPage))?.id ?? sections[0]?.id ?? "",
|
||||
[currentPage, sections]
|
||||
);
|
||||
const [openSectionId, setOpenSectionId] = useState(activeSectionId);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenSectionId(activeSectionId);
|
||||
}, [activeSectionId]);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<h1>TuxDMX</h1>
|
||||
<p>Headless lysstyring med simulator, ærlig telemetri og Twitch-klar API.</p>
|
||||
</div>
|
||||
<nav className="nav-list" aria-label="Primær navigation">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={section.id}
|
||||
className={`nav-section nav-section-${section.tone ?? "muted"} ${openSectionId === section.id ? "open" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-section-trigger ${openSectionId === section.id ? "active" : ""}`}
|
||||
aria-expanded={openSectionId === section.id}
|
||||
onClick={() => setOpenSectionId((current) => (current === section.id ? activeSectionId : section.id))}
|
||||
>
|
||||
<span className="nav-section-trigger-main">
|
||||
<span className="nav-section-icon">
|
||||
<SectionIcon name={section.icon} />
|
||||
</span>
|
||||
<span className="nav-section-copy">
|
||||
<span className="nav-section-label">{section.label}</span>
|
||||
<span className="nav-section-meta">{section.items.length} sider</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="nav-section-chevron" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m8 10 4 4 4-4" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{openSectionId === section.id ? (
|
||||
<div className="nav-section-items">
|
||||
{section.items.map((page) => (
|
||||
<button
|
||||
key={page.id}
|
||||
className={`nav-button ${currentPage === page.id ? "active" : ""}`}
|
||||
onClick={() => onNavigate(page.id)}
|
||||
>
|
||||
<span className="nav-button-icon">
|
||||
<NavIcon name={page.icon} />
|
||||
</span>
|
||||
<span className="nav-button-label">{page.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="content">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
type ChannelGridProps = {
|
||||
values: number[];
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
export function ChannelGrid({ values, sources }: ChannelGridProps) {
|
||||
const activeChannels = values
|
||||
.map((value, index) => ({
|
||||
channel: index + 1,
|
||||
value,
|
||||
source: sources[index] ?? "idle"
|
||||
}))
|
||||
.filter((entry) => entry.value > 0 || entry.source !== "idle");
|
||||
|
||||
if (activeChannels.length === 0) {
|
||||
return (
|
||||
<div className="event-item">
|
||||
<strong>Ingen aktive DMX-kanaler</strong>
|
||||
<div className="muted">Monitoren viser kun kanaler med værdi over 0 eller aktiv kilde.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="channel-grid" aria-label="DMX kanalgrid">
|
||||
{activeChannels.map((entry) => (
|
||||
<div
|
||||
key={entry.channel}
|
||||
className="channel-cell"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, rgba(0,229,255,${Math.max(
|
||||
0.08,
|
||||
entry.value / 255
|
||||
)}), rgba(255,61,242,0.06))`
|
||||
}}
|
||||
title={`Kanal ${entry.channel} • Kilde ${entry.source}`}
|
||||
>
|
||||
<span>{entry.channel}</span>
|
||||
<strong>{entry.value}</strong>
|
||||
<small>{entry.source.replace(/^.*:/, "")}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
type StatusPillProps = {
|
||||
label: string;
|
||||
tone: "success" | "warning" | "danger" | "info";
|
||||
};
|
||||
|
||||
export function StatusPill({ label, tone }: StatusPillProps) {
|
||||
return <span className={`pill ${tone}`}>{label}</span>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
export type SystemStatus = {
|
||||
app: string;
|
||||
setup_required: boolean;
|
||||
engine: {
|
||||
backend: string;
|
||||
connected: boolean;
|
||||
degraded: boolean;
|
||||
last_error: string | null;
|
||||
blackout: boolean;
|
||||
fps: number;
|
||||
master: number;
|
||||
frame: number[];
|
||||
source_map: string[];
|
||||
updated_at?: string | null;
|
||||
};
|
||||
telemetry: {
|
||||
uptime_seconds: number;
|
||||
cpu_percent: number;
|
||||
ram_percent: number;
|
||||
temperature_c: number | null;
|
||||
queue_depth: number;
|
||||
reconnect_count: number;
|
||||
last_error: string | null;
|
||||
events: Array<{ created_at: string; category: string; level: string; message: string }>;
|
||||
};
|
||||
bpm: BpmStatus;
|
||||
};
|
||||
|
||||
export type DmxOutputConfig = {
|
||||
backend: "simulator" | "ola" | "artnet";
|
||||
universe: number;
|
||||
output_port: string;
|
||||
target_host: string;
|
||||
artnet_nodes: ArtNetNode[];
|
||||
};
|
||||
|
||||
export type DmxDevice = {
|
||||
name: string;
|
||||
backend: string;
|
||||
connected: boolean;
|
||||
output_port: string | null;
|
||||
universe: number;
|
||||
};
|
||||
|
||||
export type HomeAssistantConfig = {
|
||||
enabled: boolean;
|
||||
base_url: string;
|
||||
default_universe: number;
|
||||
has_token: boolean;
|
||||
token_mask: string;
|
||||
mapping_count: number;
|
||||
dispatch_count: number;
|
||||
error_count: number;
|
||||
last_error: string | null;
|
||||
last_successful_call_at: string | null;
|
||||
last_connection_success_at: string | null;
|
||||
last_connection_error: string | null;
|
||||
ha_version: string | null;
|
||||
auth_ok: boolean;
|
||||
reachable: boolean;
|
||||
};
|
||||
|
||||
export type HomeAssistantConnectionResult = {
|
||||
reachable: boolean;
|
||||
auth_ok: boolean;
|
||||
ha_version: string | null;
|
||||
last_error: string | null;
|
||||
last_successful_call_at: string | null;
|
||||
last_connection_success_at: string | null;
|
||||
};
|
||||
|
||||
export type HomeAssistantEntity = {
|
||||
entity_id: string;
|
||||
domain: string;
|
||||
friendly_name: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type HomeAssistantMapping = {
|
||||
id: number;
|
||||
name: string;
|
||||
universe: number;
|
||||
start_address: number;
|
||||
fixture_type: "dimmer" | "rgb" | "rgbw" | "cct" | "switch" | "scene" | "automation";
|
||||
entity_id: string;
|
||||
rate_limit_hz: number;
|
||||
deadband: number;
|
||||
fade_ms: number;
|
||||
invert_channel: boolean;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
enabled: boolean;
|
||||
master_dimmer: boolean;
|
||||
channel_span: number;
|
||||
status: string;
|
||||
last_dmx_values: number[];
|
||||
last_sent_summary: string | null;
|
||||
last_service: string | null;
|
||||
last_success_at: string | null;
|
||||
last_error: string | null;
|
||||
last_error_at: string | null;
|
||||
in_flight: boolean;
|
||||
};
|
||||
|
||||
export type MidiBridgeStatus = {
|
||||
bridge_id: string;
|
||||
device_name: string;
|
||||
ip_address: string | null;
|
||||
protocol_version: number;
|
||||
online: boolean;
|
||||
last_heartbeat_at: string | null;
|
||||
last_event_at: string | null;
|
||||
last_error: string | null;
|
||||
last_event: {
|
||||
type: string;
|
||||
channel: number;
|
||||
number: number;
|
||||
value: number;
|
||||
} | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type MidiMapping = {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
bridge_id: string | null;
|
||||
device_name: string | null;
|
||||
message_type: "note_on" | "note_off" | "control_change" | "program_change";
|
||||
channel: number | null;
|
||||
number: number;
|
||||
action:
|
||||
| "activate_scene"
|
||||
| "deactivate_scene"
|
||||
| "toggle_scene"
|
||||
| "flash_scene"
|
||||
| "blackout_on"
|
||||
| "blackout_off"
|
||||
| "blackout_toggle"
|
||||
| "set_master_dimmer"
|
||||
| "set_scene_intensity"
|
||||
| "start_effect"
|
||||
| "stop_effect";
|
||||
target_type: "scene" | "effect" | "system" | "global";
|
||||
target_id: string | null;
|
||||
mode: "trigger" | "toggle" | "hold" | "flash" | "continuous";
|
||||
minimum_value: number;
|
||||
maximum_value: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type MidiBridgeToken = {
|
||||
id: number;
|
||||
label: string;
|
||||
bridge_id: string | null;
|
||||
scopes: string[];
|
||||
created_at: string;
|
||||
revoked_at: string | null;
|
||||
last_used_at: string | null;
|
||||
token_preview: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type MidiLearnState = {
|
||||
active: boolean;
|
||||
allow_passthrough: boolean;
|
||||
expires_at: string | null;
|
||||
captured_event: {
|
||||
bridge_id: string;
|
||||
device: string;
|
||||
timestamp: string;
|
||||
message: {
|
||||
type: string;
|
||||
channel: number;
|
||||
number: number;
|
||||
value: number;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ArtNetNode = {
|
||||
ip: string;
|
||||
short_name: string;
|
||||
long_name: string;
|
||||
label: string;
|
||||
net: number;
|
||||
sub_switch: number;
|
||||
port_count: number;
|
||||
raw_port_address: number;
|
||||
};
|
||||
|
||||
export type BpmDevice = {
|
||||
id: string;
|
||||
name: string;
|
||||
backend: string;
|
||||
is_default: boolean;
|
||||
recommended?: boolean;
|
||||
};
|
||||
|
||||
export type BpmStatus = {
|
||||
mode: string;
|
||||
bpm: number;
|
||||
confidence: number;
|
||||
devices: string[];
|
||||
device_details?: BpmDevice[];
|
||||
current_device: string;
|
||||
selected_device: string;
|
||||
recommended_device: string;
|
||||
audio_connected: boolean;
|
||||
last_error: string | null;
|
||||
last_beat_at?: number | null;
|
||||
input_level: number;
|
||||
peak_level: number;
|
||||
clipping: boolean;
|
||||
sample_rate: number;
|
||||
channels: number;
|
||||
format: string;
|
||||
};
|
||||
|
||||
export type BpmConfig = {
|
||||
preferred_device: string;
|
||||
recommended_device: string;
|
||||
sample_rate: number;
|
||||
channels: number;
|
||||
format: string;
|
||||
};
|
||||
|
||||
export type FixturePreview = {
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
categories: string[];
|
||||
schema_version: string;
|
||||
modes: Array<{
|
||||
key: string;
|
||||
channel_count: number;
|
||||
channels: Array<{
|
||||
index: number;
|
||||
key: string;
|
||||
display_name: string;
|
||||
precedence: string;
|
||||
resolution: number;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type FixtureSummary = {
|
||||
id: number;
|
||||
slug: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
short_name: string | null;
|
||||
categories: string[];
|
||||
schema_version: string;
|
||||
source: {
|
||||
manufacturer_key?: string;
|
||||
fixture_key?: string;
|
||||
};
|
||||
warnings: string[];
|
||||
modes: Array<{
|
||||
key: string;
|
||||
channel_count: number;
|
||||
channels: Array<{
|
||||
index: number;
|
||||
key: string;
|
||||
display_name: string;
|
||||
precedence: string;
|
||||
resolution: number;
|
||||
capabilities?: unknown[];
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PatchItem = {
|
||||
id: number;
|
||||
universe: number;
|
||||
name: string;
|
||||
definition_id: number;
|
||||
fixture_slug: string | null;
|
||||
manufacturer: string | null;
|
||||
model: string | null;
|
||||
mode_key: string;
|
||||
start_address: number;
|
||||
end_address: number;
|
||||
channel_count: number;
|
||||
enabled: boolean;
|
||||
group_names: string[];
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
rotation: number;
|
||||
};
|
||||
channels: Array<{
|
||||
index: number;
|
||||
key: string;
|
||||
display_name: string;
|
||||
precedence: string;
|
||||
resolution: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SceneItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string;
|
||||
icon: string | null;
|
||||
priority: number;
|
||||
fade_in_ms: number;
|
||||
fade_out_ms: number;
|
||||
hold_ms: number;
|
||||
master_limit: number;
|
||||
values: Array<{
|
||||
channel: number;
|
||||
value: number;
|
||||
precedence: string;
|
||||
source?: string;
|
||||
}>;
|
||||
targets: Array<{
|
||||
target_type: "fixture" | "group";
|
||||
patch_id?: number | null;
|
||||
group_name?: string | null;
|
||||
values: Array<{
|
||||
attribute: string;
|
||||
value: number;
|
||||
precedence?: string | null;
|
||||
}>;
|
||||
}>;
|
||||
tags: string[];
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type PatchValidation = {
|
||||
valid: boolean;
|
||||
range: string;
|
||||
end_address: number;
|
||||
channel_count: number;
|
||||
occupied_channels: number[];
|
||||
conflicts: Array<{
|
||||
type: string;
|
||||
patch_id?: number;
|
||||
name?: string;
|
||||
range?: string;
|
||||
fixture?: string;
|
||||
message: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SystemCommandResult = {
|
||||
accepted: boolean;
|
||||
action: string;
|
||||
status: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type LiveMixerItem = {
|
||||
id: string;
|
||||
source_type: "patch" | "home_assistant";
|
||||
source_id: number;
|
||||
patch_id: number | null;
|
||||
name: string;
|
||||
manufacturer: string | null;
|
||||
model: string | null;
|
||||
mode_key: string;
|
||||
universe: number;
|
||||
start_address: number;
|
||||
end_address: number;
|
||||
channel_count: number;
|
||||
entity_id?: string | null;
|
||||
status?: string | null;
|
||||
last_sent_summary?: string | null;
|
||||
channels: Array<{
|
||||
index: number;
|
||||
absolute_channel: number;
|
||||
key: string;
|
||||
display_name: string;
|
||||
precedence: string;
|
||||
resolution: number;
|
||||
value: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const jsonHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`/api/v1${path}`, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API-fejl ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => request<SystemStatus>("/system/status"),
|
||||
restartService: () => request<SystemCommandResult>("/system/restart-service", { method: "POST" }),
|
||||
rebootHost: () => request<SystemCommandResult>("/system/reboot-host", { method: "POST" }),
|
||||
getDmxConfig: () => request<DmxOutputConfig>("/dmx/config"),
|
||||
saveDmxConfig: (payload: Record<string, unknown>) =>
|
||||
request<DmxOutputConfig>("/dmx/config", {
|
||||
method: "PUT",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
discoverArtNet: (timeoutS = 1) =>
|
||||
request<{ items: ArtNetNode[]; count: number }>(`/dmx/artnet/discover?timeout_s=${timeoutS}`, {
|
||||
method: "POST"
|
||||
}),
|
||||
listDmxDevices: () => request<{ devices: DmxDevice[] }>("/dmx/devices"),
|
||||
getHomeAssistantConfig: () => request<HomeAssistantConfig>("/integrations/home-assistant/config"),
|
||||
saveHomeAssistantConfig: (payload: Record<string, unknown>) =>
|
||||
request<HomeAssistantConfig>("/integrations/home-assistant/config", {
|
||||
method: "PUT",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
testHomeAssistantConnection: () =>
|
||||
request<HomeAssistantConnectionResult>("/integrations/home-assistant/test-connection", {
|
||||
method: "POST"
|
||||
}),
|
||||
listHomeAssistantEntities: () =>
|
||||
request<{ items: HomeAssistantEntity[] }>("/integrations/home-assistant/entities"),
|
||||
listHomeAssistantMappings: () =>
|
||||
request<{ items: HomeAssistantMapping[] }>("/integrations/home-assistant/mappings"),
|
||||
createHomeAssistantMapping: (payload: Record<string, unknown>) =>
|
||||
request<HomeAssistantMapping>("/integrations/home-assistant/mappings", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
updateHomeAssistantMapping: (mappingId: number, payload: Record<string, unknown>) =>
|
||||
request<HomeAssistantMapping>(`/integrations/home-assistant/mappings/${mappingId}`, {
|
||||
method: "PUT",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
deleteHomeAssistantMapping: (mappingId: number) =>
|
||||
request<Record<string, unknown>>(`/integrations/home-assistant/mappings/${mappingId}`, { method: "DELETE" }),
|
||||
testHomeAssistantMapping: (mappingId: number) =>
|
||||
request<Record<string, unknown>>(`/integrations/home-assistant/mappings/${mappingId}/test`, {
|
||||
method: "POST"
|
||||
}),
|
||||
listMidiBridges: () => request<{ items: MidiBridgeStatus[] }>("/integrations/midi/bridges"),
|
||||
listMidiMappings: () => request<{ items: MidiMapping[] }>("/integrations/midi/mappings"),
|
||||
createMidiMapping: (payload: Record<string, unknown>) =>
|
||||
request<MidiMapping>("/integrations/midi/mappings", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
updateMidiMapping: (mappingId: number, payload: Record<string, unknown>) =>
|
||||
request<MidiMapping>(`/integrations/midi/mappings/${mappingId}`, {
|
||||
method: "PUT",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
deleteMidiMapping: (mappingId: number) =>
|
||||
request<Record<string, unknown>>(`/integrations/midi/mappings/${mappingId}`, { method: "DELETE" }),
|
||||
testMidiMapping: (mappingId: number, value = 127) =>
|
||||
request<Record<string, unknown>>(`/integrations/midi/mappings/${mappingId}/test`, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ value })
|
||||
}),
|
||||
listMidiTokens: () => request<{ items: MidiBridgeToken[] }>("/integrations/midi/tokens"),
|
||||
createMidiToken: (payload: Record<string, unknown>) =>
|
||||
request<MidiBridgeToken>("/integrations/midi/tokens", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
revokeMidiToken: (tokenId: number) =>
|
||||
request<Record<string, unknown>>(`/integrations/midi/tokens/${tokenId}`, { method: "DELETE" }),
|
||||
getMidiLearnState: () => request<MidiLearnState>("/integrations/midi/learn"),
|
||||
startMidiLearn: (timeoutSeconds = 15, allowPassthrough = false) =>
|
||||
request<MidiLearnState>("/integrations/midi/learn/start", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ timeout_seconds: timeoutSeconds, allow_passthrough: allowPassthrough })
|
||||
}),
|
||||
cancelMidiLearn: () =>
|
||||
request<MidiLearnState>("/integrations/midi/learn/cancel", {
|
||||
method: "POST"
|
||||
}),
|
||||
getFrame: () => request<{ universe: number; values: number[]; sources: string[] }>("/dmx/frame"),
|
||||
getFixtures: () => request<{ items: FixtureSummary[] }>("/fixtures"),
|
||||
searchFixtures: (query: string) =>
|
||||
request<{ results: Array<{ fixture_key: string; cached: boolean }> }>(
|
||||
`/fixtures/search-ofl?query=${encodeURIComponent(query)}`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
previewFixture: (manufacturerKey: string, fixtureKey: string) =>
|
||||
request<FixturePreview>(
|
||||
`/fixtures/preview-ofl?manufacturer_key=${encodeURIComponent(manufacturerKey)}&fixture_key=${encodeURIComponent(fixtureKey)}`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
importFixture: (manufacturerKey: string, fixtureKey: string) =>
|
||||
request<FixtureSummary>(
|
||||
`/fixtures/import-ofl?manufacturer_key=${encodeURIComponent(manufacturerKey)}&fixture_key=${encodeURIComponent(fixtureKey)}`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
importFixtureFile: (payload: Record<string, unknown>) =>
|
||||
request<FixtureSummary>("/fixtures/import-file", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
getLiveMixer: () => request<{ items: LiveMixerItem[] }>("/live/mixer"),
|
||||
setLiveMixerPatchValues: (patchId: number, values: Record<number, number>) =>
|
||||
request<{ items: LiveMixerItem[] }>(`/live/mixer/${patchId}`, {
|
||||
method: "PUT",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ values })
|
||||
}),
|
||||
setLiveMixerHomeAssistantValues: (mappingId: number, values: Record<number, number>) =>
|
||||
request<{ items: LiveMixerItem[] }>(`/live/mixer/home-assistant/${mappingId}`, {
|
||||
method: "PUT",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ values })
|
||||
}),
|
||||
clearLiveMixerPatchValues: (patchId: number) =>
|
||||
request<{ items: LiveMixerItem[] }>(`/live/mixer/${patchId}`, { method: "DELETE" }),
|
||||
clearLiveMixerHomeAssistantValues: (mappingId: number) =>
|
||||
request<{ items: LiveMixerItem[] }>(`/live/mixer/home-assistant/${mappingId}`, { method: "DELETE" }),
|
||||
listPatches: () => request<{ items: PatchItem[] }>("/patch"),
|
||||
validatePatch: (payload: Record<string, unknown>) =>
|
||||
request<PatchValidation>("/patch/validate", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
createPatch: (payload: Record<string, unknown>) =>
|
||||
request<PatchItem>("/patch", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
updatePatch: (patchId: number, payload: Record<string, unknown>) =>
|
||||
request<PatchItem>(`/patch/${patchId}`, {
|
||||
method: "PUT",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
deletePatch: (patchId: number) => request<Record<string, unknown>>(`/patch/${patchId}`, { method: "DELETE" }),
|
||||
listScenes: () => request<{ items: SceneItem[] }>("/scenes"),
|
||||
createScene: (payload: Record<string, unknown>) =>
|
||||
request<SceneItem>("/scenes", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
activateScene: (slug: string) =>
|
||||
request<SceneItem>(`/scenes/${encodeURIComponent(slug)}/activate`, { method: "POST" }),
|
||||
releaseScene: (slug: string) =>
|
||||
request<Record<string, unknown>>(`/scenes/${encodeURIComponent(slug)}/release`, { method: "POST" }),
|
||||
deleteScene: (sceneId: number) =>
|
||||
request<Record<string, unknown>>(`/scenes/${sceneId}`, { method: "DELETE" }),
|
||||
listEffects: () => request<{ items: Array<Record<string, unknown>> }>("/effects"),
|
||||
createEffect: (payload: Record<string, unknown>) =>
|
||||
request<Record<string, unknown>>("/effects", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
triggerEffect: (slug: string) =>
|
||||
request<Record<string, unknown>>(`/effects/${encodeURIComponent(slug)}/trigger`, { method: "POST" }),
|
||||
stopEffect: (slug: string) =>
|
||||
request<Record<string, unknown>>(`/effects/${encodeURIComponent(slug)}/stop`, { method: "POST" }),
|
||||
tapBpm: () => request<Record<string, unknown>>("/bpm/tap", { method: "POST" }),
|
||||
setManualBpm: (bpm: number) =>
|
||||
request<Record<string, unknown>>(`/bpm/manual?bpm=${bpm}`, { method: "POST" }),
|
||||
startAudioBpm: (device: string) =>
|
||||
request<BpmStatus>("/bpm/audio/start", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ device })
|
||||
}),
|
||||
stopAudioBpm: () =>
|
||||
request<Record<string, string>>("/bpm/audio/stop", { method: "POST" }),
|
||||
listBpmDevices: () => request<{ devices: BpmDevice[] }>("/bpm/devices"),
|
||||
getBpmConfig: () => request<BpmConfig>("/bpm/config"),
|
||||
saveBpmConfig: (preferredDevice: string) =>
|
||||
request<BpmConfig>("/bpm/config", {
|
||||
method: "PUT",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ preferred_device: preferredDevice })
|
||||
}),
|
||||
blackout: () => request<Record<string, unknown>>("/dmx/blackout", { method: "POST" }),
|
||||
releaseBlackout: () => request<Record<string, unknown>>("/dmx/release-blackout", { method: "POST" }),
|
||||
listBackups: () => request<{ items: Array<Record<string, unknown>> }>("/backups"),
|
||||
createBackup: () => request<Record<string, unknown>>("/backups", { method: "POST" }),
|
||||
restoreBackup: (backupId: string) =>
|
||||
request<Record<string, unknown>>(`/backups/${encodeURIComponent(backupId)}/restore`, {
|
||||
method: "POST"
|
||||
}),
|
||||
triggerMixitup: (slug: string, payload: Record<string, unknown>) =>
|
||||
request<Record<string, unknown>>(`/triggers/${encodeURIComponent(slug)}`, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
type BackupPageProps = {
|
||||
backups: Array<Record<string, unknown>>;
|
||||
onCreate: () => Promise<void>;
|
||||
onRestore: (backupId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function BackupPage({ backups, onCreate, onRestore }: BackupPageProps) {
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>Backup og restore</h2>
|
||||
<p className="muted">
|
||||
Release 1.0 inkluderer backup-endpoints, Linux-opdateringsflow og dokumenteret pre-restore
|
||||
backup.
|
||||
</p>
|
||||
</div>
|
||||
<div className="event-item">
|
||||
<strong>Bemærk</strong>
|
||||
<div className="muted">
|
||||
Restore og download skal altid verificeres mod den rigtige Debian-installation. Denne
|
||||
session påstår ikke direkte fysisk hardwaremåling.
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button onClick={onCreate}>Opret backup</button>
|
||||
</div>
|
||||
<div className="event-list">
|
||||
{backups.map((backup) => (
|
||||
<div key={String(backup.id)} className="event-item">
|
||||
<strong>{String(backup.label)}</strong>
|
||||
<div className="muted">{String(backup.created_at ?? "ukendt tidspunkt")}</div>
|
||||
<div className="button-row">
|
||||
<button onClick={() => onRestore(String(backup.id))}>Restore</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { BpmDevice, BpmStatus, PatchItem } from "../lib/api";
|
||||
|
||||
type BpmPageProps = {
|
||||
status: BpmStatus | null;
|
||||
devices: BpmDevice[];
|
||||
patches: PatchItem[];
|
||||
onTap: () => Promise<void>;
|
||||
onSetManual: (bpm: number) => Promise<void>;
|
||||
onStartAudio: (device: string) => Promise<void>;
|
||||
onStopAudio: () => Promise<void>;
|
||||
onCreateEffect: (payload: Record<string, unknown>) => Promise<void>;
|
||||
onTriggerEffect: (slug: string) => Promise<void>;
|
||||
onStopEffect: (slug: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function BpmPage({
|
||||
status,
|
||||
devices,
|
||||
patches,
|
||||
onTap,
|
||||
onSetManual,
|
||||
onStartAudio,
|
||||
onStopAudio,
|
||||
onCreateEffect,
|
||||
onTriggerEffect,
|
||||
onStopEffect
|
||||
}: BpmPageProps) {
|
||||
const [manualBpm, setManualBpm] = useState<number>(status?.bpm ?? 120);
|
||||
const [selectedDevice, setSelectedDevice] = useState<string>(
|
||||
status?.selected_device && status.selected_device !== "manual"
|
||||
? status.selected_device
|
||||
: (devices[0]?.id ?? "alsa:auto")
|
||||
);
|
||||
const [targetMode, setTargetMode] = useState<"fixture" | "group">("fixture");
|
||||
const [selectedPatchId, setSelectedPatchId] = useState<number>(patches[0]?.id ?? 0);
|
||||
const [selectedGroupName, setSelectedGroupName] = useState<string>("");
|
||||
const [selectedAttribute, setSelectedAttribute] = useState<string>("");
|
||||
const [pulseValue, setPulseValue] = useState<number>(255);
|
||||
const [pulseDurationMs, setPulseDurationMs] = useState<number>(180);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const [displayLevel, setDisplayLevel] = useState<number>(status?.input_level ?? 0);
|
||||
const [displayPeak, setDisplayPeak] = useState<number>(status?.peak_level ?? 0);
|
||||
const levelTargetRef = useRef(status?.input_level ?? 0);
|
||||
const peakTargetRef = useRef(status?.peak_level ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.selected_device && status.selected_device !== "manual") {
|
||||
setSelectedDevice(status.selected_device);
|
||||
}
|
||||
}, [status?.selected_device]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!patches.length) {
|
||||
setSelectedPatchId(0);
|
||||
return;
|
||||
}
|
||||
if (!patches.some((patch) => patch.id === selectedPatchId)) {
|
||||
setSelectedPatchId(patches[0].id);
|
||||
}
|
||||
}, [patches, selectedPatchId]);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => Array.from(new Set(patches.flatMap((patch) => patch.group_names))).sort((left, right) => left.localeCompare(right)),
|
||||
[patches]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGroupName && groupOptions[0]) {
|
||||
setSelectedGroupName(groupOptions[0]);
|
||||
}
|
||||
if (selectedGroupName && !groupOptions.includes(selectedGroupName)) {
|
||||
setSelectedGroupName(groupOptions[0] ?? "");
|
||||
}
|
||||
}, [groupOptions, selectedGroupName]);
|
||||
|
||||
const selectedPatch = useMemo(
|
||||
() => patches.find((patch) => patch.id === selectedPatchId) ?? patches[0] ?? null,
|
||||
[patches, selectedPatchId]
|
||||
);
|
||||
|
||||
const targetPatches = useMemo(() => {
|
||||
if (targetMode === "fixture") {
|
||||
return selectedPatch ? [selectedPatch] : [];
|
||||
}
|
||||
return patches.filter((patch) => patch.group_names.includes(selectedGroupName));
|
||||
}, [patches, selectedGroupName, selectedPatch, targetMode]);
|
||||
|
||||
const attributeOptions = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
targetPatches.flatMap((patch) =>
|
||||
patch.channels.map((channel) => channel.display_name)
|
||||
)
|
||||
)
|
||||
),
|
||||
[targetPatches]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!attributeOptions.length) {
|
||||
setSelectedAttribute("");
|
||||
return;
|
||||
}
|
||||
if (!selectedAttribute || !attributeOptions.includes(selectedAttribute)) {
|
||||
setSelectedAttribute(attributeOptions[0]);
|
||||
}
|
||||
}, [attributeOptions, selectedAttribute]);
|
||||
|
||||
useEffect(() => {
|
||||
levelTargetRef.current = status?.input_level ?? 0;
|
||||
peakTargetRef.current = status?.peak_level ?? 0;
|
||||
}, [status?.input_level, status?.peak_level]);
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0;
|
||||
const animate = () => {
|
||||
setDisplayLevel((current) => {
|
||||
const next = current + (levelTargetRef.current - current) * 0.42;
|
||||
return Math.abs(next - levelTargetRef.current) < 0.003 ? levelTargetRef.current : next;
|
||||
});
|
||||
setDisplayPeak((current) => {
|
||||
const next = current + (peakTargetRef.current - current) * 0.28;
|
||||
return Math.abs(next - peakTargetRef.current) < 0.003 ? peakTargetRef.current : next;
|
||||
});
|
||||
frameId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
frameId = window.requestAnimationFrame(animate);
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, []);
|
||||
|
||||
const syncSlug = useMemo(() => {
|
||||
const targetKey =
|
||||
targetMode === "fixture"
|
||||
? selectedPatch?.name ?? "fixture"
|
||||
: selectedGroupName || "group";
|
||||
return slugify(`bpm-sync-${targetMode}-${targetKey}-${selectedAttribute || "channel"}`);
|
||||
}, [selectedAttribute, selectedGroupName, selectedPatch?.name, targetMode]);
|
||||
|
||||
const syncTargets = useMemo(() => {
|
||||
const channels = Object.fromEntries(
|
||||
targetPatches.flatMap((patch) => {
|
||||
const channel = patch.channels.find((item) => item.display_name === selectedAttribute);
|
||||
if (!channel) {
|
||||
return [];
|
||||
}
|
||||
const absoluteChannel = patch.start_address + channel.index - 1;
|
||||
return [[absoluteChannel, pulseValue]];
|
||||
})
|
||||
);
|
||||
const precedence = Object.fromEntries(
|
||||
targetPatches.flatMap((patch) => {
|
||||
const channel = patch.channels.find((item) => item.display_name === selectedAttribute);
|
||||
if (!channel) {
|
||||
return [];
|
||||
}
|
||||
const absoluteChannel = patch.start_address + channel.index - 1;
|
||||
return [[absoluteChannel, channel.precedence]];
|
||||
})
|
||||
);
|
||||
return { channels, precedence };
|
||||
}, [pulseValue, selectedAttribute, targetPatches]);
|
||||
|
||||
async function handleSaveAndStartSync(): Promise<void> {
|
||||
if (!selectedAttribute || !targetPatches.length || Object.keys(syncTargets.channels).length === 0) {
|
||||
setSyncMessage("Vælg mindst én patched lampe og en gyldig attribut før BPM-sync kan startes.");
|
||||
return;
|
||||
}
|
||||
await onCreateEffect({
|
||||
name:
|
||||
targetMode === "fixture"
|
||||
? `BPM sync · ${selectedPatch?.name ?? "Fixture"} · ${selectedAttribute}`
|
||||
: `BPM sync · ${selectedGroupName} · ${selectedAttribute}`,
|
||||
slug: syncSlug,
|
||||
effect_type: "beat-flash",
|
||||
priority: 60,
|
||||
cooldown_ms: 0,
|
||||
parameters: {
|
||||
duration_ms: pulseDurationMs,
|
||||
channels: syncTargets.channels,
|
||||
precedence: syncTargets.precedence,
|
||||
source: "bpm"
|
||||
}
|
||||
});
|
||||
await onTriggerEffect(syncSlug);
|
||||
setSyncMessage(
|
||||
targetMode === "fixture"
|
||||
? `BPM-sync kører nu på ${selectedPatch?.name ?? "fixture"} / ${selectedAttribute}.`
|
||||
: `BPM-sync kører nu på gruppe ${selectedGroupName} / ${selectedAttribute}.`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStopSync(): Promise<void> {
|
||||
await onStopEffect(syncSlug);
|
||||
setSyncMessage("BPM-sync er stoppet for det valgte target.");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>Lyd og BPM</h2>
|
||||
<p className="muted">
|
||||
BPM kan drives af manuel værdi, tap-tempo eller rigtig audio-input via ALSA. Ved tab af
|
||||
input falder systemet tilbage til manuel BPM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<article className="status-card summary-panel">
|
||||
<div className="compact-stat-bar dense" aria-label="BPM status">
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">BPM</span>
|
||||
<strong>{status?.bpm ?? 0}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Confidence</span>
|
||||
<strong>{Math.round((status?.confidence ?? 0) * 100)}%</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Kilde</span>
|
||||
<strong>{status?.mode ?? "ukendt"}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Input</span>
|
||||
<strong>{status?.audio_connected ? "Live" : "Standby"}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Level</span>
|
||||
<strong>{Math.round((status?.input_level ?? 0) * 100)}%</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Aktiv device</span>
|
||||
<strong>{status?.audio_connected ? status.current_device.replace(/^alsa:/, "") : "Ingen"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="workspace-grid">
|
||||
<article className="status-card section-stack">
|
||||
<h3>Manual og tap</h3>
|
||||
<label>
|
||||
<span className="field-label">Manual BPM</span>
|
||||
<input
|
||||
type="number"
|
||||
min={40}
|
||||
max={220}
|
||||
value={manualBpm}
|
||||
onChange={(event) => setManualBpm(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => onSetManual(manualBpm)}>
|
||||
Brug manuel BPM
|
||||
</button>
|
||||
<button type="button" onClick={onTap}>
|
||||
Tap tempo
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="status-card section-stack">
|
||||
<h3>Audio-input</h3>
|
||||
<div className="vu-card">
|
||||
<div className="vu-card-header">
|
||||
<strong>VU-meter</strong>
|
||||
<span className={`pill ${status?.clipping ? "danger" : status?.audio_connected ? "success" : "info"}`}>
|
||||
{status?.clipping ? "Clip" : status?.audio_connected ? "Signal" : "Ingen signal"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vu-meter" aria-label="Inputniveau">
|
||||
<div
|
||||
className={`vu-meter-fill ${status?.clipping ? "clip" : ""}`}
|
||||
style={{ width: `${Math.max(2, Math.round(displayLevel * 100))}%` }}
|
||||
/>
|
||||
<div
|
||||
className="vu-meter-peak"
|
||||
style={{ left: `${Math.round(displayPeak * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="vu-meter-scale">
|
||||
<span>0</span>
|
||||
<span>25</span>
|
||||
<span>50</span>
|
||||
<span>75</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
<div className="vu-meter-readout muted">
|
||||
Niveau {Math.round(displayLevel * 100)}% · Peak {Math.round(displayPeak * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
<span className="field-label">Device</span>
|
||||
<select value={selectedDevice} onChange={(event) => setSelectedDevice(event.target.value)}>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name}
|
||||
{device.recommended ? " · anbefalet" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="muted">
|
||||
Valgt som standard: {(status?.selected_device ?? "alsa:auto").replace(/^alsa:/, "")}
|
||||
{" · "}
|
||||
Aktivt input: {status?.audio_connected ? status.current_device.replace(/^alsa:/, "") : "ingen"}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
{status?.audio_connected ? (
|
||||
<>
|
||||
<button type="button" onClick={onStopAudio}>
|
||||
Stop audioanalyse
|
||||
</button>
|
||||
<button type="button" onClick={() => onStartAudio(selectedDevice)}>
|
||||
Genstart audioanalyse
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={() => onStartAudio(selectedDevice)}>
|
||||
Start audioanalyse
|
||||
</button>
|
||||
<button type="button" onClick={onStopAudio} disabled>
|
||||
Stop audioanalyse
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{status?.last_error ? (
|
||||
<div className="event-item wizard">
|
||||
<strong>Seneste fejl</strong>
|
||||
<div className="muted">{status.last_error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="muted">
|
||||
Input åbnes som {status?.format ?? "S16_LE"} · {status?.sample_rate ?? 44100} Hz ·{" "}
|
||||
{status?.channels ?? 1} kanal.
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="workspace-grid">
|
||||
<article className="status-card section-stack">
|
||||
<h3>BPM-synk til lamper</h3>
|
||||
<p className="muted">
|
||||
Her binder du BPM direkte til patched fixtures eller en hel gruppe. Vælg fx dimmer,
|
||||
strobe eller farve, og start sync uden at hoppe over i andre sider.
|
||||
</p>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">Target-type</span>
|
||||
<select value={targetMode} onChange={(event) => setTargetMode(event.target.value as "fixture" | "group")}>
|
||||
<option value="fixture">Fixture</option>
|
||||
<option value="group">Gruppe</option>
|
||||
</select>
|
||||
</label>
|
||||
{targetMode === "fixture" ? (
|
||||
<label>
|
||||
<span className="field-label">Fixture</span>
|
||||
<select value={selectedPatchId} onChange={(event) => setSelectedPatchId(Number(event.target.value))}>
|
||||
{patches.map((patch) => (
|
||||
<option key={patch.id} value={patch.id}>
|
||||
{patch.name} · U{patch.universe} · {patch.start_address}-{patch.end_address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<label>
|
||||
<span className="field-label">Gruppe</span>
|
||||
<select value={selectedGroupName} onChange={(event) => setSelectedGroupName(event.target.value)}>
|
||||
{groupOptions.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{group}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
<span className="field-label">Attribut</span>
|
||||
<select value={selectedAttribute} onChange={(event) => setSelectedAttribute(event.target.value)}>
|
||||
{attributeOptions.map((attribute) => (
|
||||
<option key={attribute} value={attribute}>
|
||||
{attribute}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Peak-værdi</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={255}
|
||||
value={pulseValue}
|
||||
onChange={(event) => setPulseValue(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Pulse (ms)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
max={1000}
|
||||
value={pulseDurationMs}
|
||||
onChange={(event) => setPulseDurationMs(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => void handleSaveAndStartSync()} disabled={!targetPatches.length || !selectedAttribute}>
|
||||
Start BPM-sync på lamper
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleStopSync()} disabled={!selectedAttribute}>
|
||||
Stop BPM-sync
|
||||
</button>
|
||||
</div>
|
||||
{syncMessage ? <div className="muted">{syncMessage}</div> : null}
|
||||
</article>
|
||||
|
||||
<article className="status-card section-stack">
|
||||
<h3>Aktivt BPM-target</h3>
|
||||
<div className="event-item">
|
||||
<strong>{targetMode === "fixture" ? selectedPatch?.name ?? "Ingen fixture" : selectedGroupName || "Ingen gruppe"}</strong>
|
||||
<div className="muted">
|
||||
{targetMode === "fixture"
|
||||
? `1 lampe valgt`
|
||||
: `${targetPatches.length} lamper i gruppen`}
|
||||
{" · "}
|
||||
Attribut {selectedAttribute || "ikke valgt"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="event-list">
|
||||
{targetPatches.map((patch) => {
|
||||
const channel = patch.channels.find((item) => item.display_name === selectedAttribute);
|
||||
return (
|
||||
<div key={patch.id} className="event-item">
|
||||
<strong>{patch.name}</strong>
|
||||
<div className="muted">
|
||||
{channel
|
||||
? `${selectedAttribute} · DMX ${patch.start_address + channel.index - 1} · ${channel.precedence.toUpperCase()}`
|
||||
: `${selectedAttribute || "Attribut"} findes ikke på denne fixture`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article className="status-card section-stack">
|
||||
<span className="status-label">Tilgængelige devices</span>
|
||||
<div className="event-list">
|
||||
{devices.map((device) => (
|
||||
<div key={device.id} className="event-item">
|
||||
<strong>{device.name}</strong>
|
||||
<div className="muted">
|
||||
{device.id} · backend {device.backend}
|
||||
{device.is_default ? " · default" : ""}
|
||||
{device.recommended ? " · anbefalet" : ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { SystemStatus } from "../lib/api";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
|
||||
type DashboardPageProps = {
|
||||
status: SystemStatus | null;
|
||||
onBlackout: () => void;
|
||||
onReleaseBlackout: () => void;
|
||||
onRestartService: () => Promise<string>;
|
||||
onRebootHost: () => Promise<string>;
|
||||
};
|
||||
|
||||
export function DashboardPage({
|
||||
status,
|
||||
onBlackout,
|
||||
onReleaseBlackout,
|
||||
onRestartService,
|
||||
onRebootHost
|
||||
}: DashboardPageProps) {
|
||||
const [systemMessage, setSystemMessage] = useState<string | null>(null);
|
||||
|
||||
if (!status) {
|
||||
return <section className="panel">Indlæser systemstatus…</section>;
|
||||
}
|
||||
|
||||
const tone = status.engine.blackout
|
||||
? "danger"
|
||||
: status.engine.degraded || !status.engine.connected
|
||||
? "warning"
|
||||
: "success";
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="hero">
|
||||
<div className="panel">
|
||||
<div className="pill-row">
|
||||
<StatusPill label={`Backend: ${status.engine.backend}`} tone="info" />
|
||||
<StatusPill
|
||||
label={status.engine.blackout ? "Blackout aktiv" : "Output klar"}
|
||||
tone={tone}
|
||||
/>
|
||||
<StatusPill label={`BPM ${status.bpm.bpm}`} tone="info" />
|
||||
<StatusPill label={`Kø ${status.telemetry.queue_depth}`} tone="warning" />
|
||||
</div>
|
||||
<h2>Operations-dashboard</h2>
|
||||
<p className="muted">
|
||||
Softwaren viser beregnet og afleveret frame. Den beviser ikke alene fysisk DMX-signal
|
||||
ved lampen.
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<button onClick={onBlackout}>Aktivér blackout</button>
|
||||
<button onClick={onReleaseBlackout}>Frigiv blackout</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setSystemMessage(await onRestartService());
|
||||
}}
|
||||
>
|
||||
Genstart TuxDMX
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!window.confirm("Vil du genstarte hele computeren nu?")) {
|
||||
return;
|
||||
}
|
||||
setSystemMessage(await onRebootHost());
|
||||
}}
|
||||
>
|
||||
Genstart computer
|
||||
</button>
|
||||
</div>
|
||||
{systemMessage ? <div className="muted">{systemMessage}</div> : null}
|
||||
</div>
|
||||
<div className="panel wizard">
|
||||
<h3>Førstegangsopsætning</h3>
|
||||
<div className="section-stack">
|
||||
<div>
|
||||
<strong>1. Administrator</strong>
|
||||
<div className="muted">Opret lokal admin, aktiver session og CSRF.</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>2. DMX/OLA</strong>
|
||||
<div className="muted">Vælg universe, port og kør en tidsbegrænset kanaltest.</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>3. Sikkerhed</strong>
|
||||
<div className="muted">Master-limit, strobegrænser, safe scene og rate limiting.</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>4. MixItUp</strong>
|
||||
<div className="muted">Generér token og kopier færdige web-request data.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="status-grid">
|
||||
<article className="status-card">
|
||||
<span className="status-label">Frame rate</span>
|
||||
<div className="status-value">{status.engine.fps}</div>
|
||||
</article>
|
||||
<article className="status-card">
|
||||
<span className="status-label">CPU</span>
|
||||
<div className="status-value">{status.telemetry.cpu_percent}%</div>
|
||||
</article>
|
||||
<article className="status-card">
|
||||
<span className="status-label">RAM</span>
|
||||
<div className="status-value">{status.telemetry.ram_percent}%</div>
|
||||
</article>
|
||||
<article className="status-card">
|
||||
<span className="status-label">Oppetid</span>
|
||||
<div className="status-value">{status.telemetry.uptime_seconds}s</div>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { PatchItem } from "../lib/api";
|
||||
|
||||
type EffectsPageProps = {
|
||||
effects: Array<Record<string, unknown>>;
|
||||
patches: PatchItem[];
|
||||
onCreate: (payload: Record<string, unknown>) => Promise<void>;
|
||||
onTrigger: (slug: string) => Promise<void>;
|
||||
onStop: (slug: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function describeEffect(effect: Record<string, unknown>): string {
|
||||
const parameters = effect.parameters;
|
||||
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
||||
return "Ingen parametre";
|
||||
}
|
||||
const typedParameters = parameters as Record<string, unknown>;
|
||||
const duration = typedParameters.duration_ms;
|
||||
const channels = typedParameters.channels;
|
||||
const channelCount = channels && typeof channels === "object" && !Array.isArray(channels)
|
||||
? Object.keys(channels).length
|
||||
: 0;
|
||||
return `Varighed ${duration ?? 0} ms · ${channelCount} kanal(er)`;
|
||||
}
|
||||
|
||||
export function EffectsPage({ effects, patches, onCreate, onTrigger, onStop }: EffectsPageProps) {
|
||||
const [name, setName] = useState("Raid flash");
|
||||
const [slug, setSlug] = useState("raid-flash");
|
||||
const [patchId, setPatchId] = useState<number>(patches[0]?.id ?? 0);
|
||||
const [channelIndex, setChannelIndex] = useState<number>(patches[0]?.channels[0]?.index ?? 1);
|
||||
const [value, setValue] = useState(255);
|
||||
const [durationMs, setDurationMs] = useState(800);
|
||||
|
||||
const selectedPatch = useMemo(
|
||||
() => patches.find((patch) => patch.id === patchId) ?? null,
|
||||
[patchId, patches]
|
||||
);
|
||||
const selectedChannel = useMemo(
|
||||
() => selectedPatch?.channels.find((channel) => channel.index === channelIndex) ?? selectedPatch?.channels[0] ?? null,
|
||||
[channelIndex, selectedPatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (patches.length === 0) {
|
||||
setPatchId(0);
|
||||
return;
|
||||
}
|
||||
if (!patches.some((patch) => patch.id === patchId)) {
|
||||
setPatchId(patches[0].id);
|
||||
}
|
||||
}, [patchId, patches]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPatch === null) {
|
||||
return;
|
||||
}
|
||||
if (!selectedPatch.channels.some((channel) => channel.index === channelIndex)) {
|
||||
setChannelIndex(selectedPatch.channels[0]?.index ?? 1);
|
||||
}
|
||||
}, [channelIndex, selectedPatch]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (selectedPatch === null || selectedChannel === null) {
|
||||
return;
|
||||
}
|
||||
const absoluteChannel = selectedPatch.start_address + selectedChannel.index - 1;
|
||||
await onCreate({
|
||||
name,
|
||||
slug,
|
||||
effect_type: "beat-flash",
|
||||
priority: 60,
|
||||
cooldown_ms: 10000,
|
||||
parameters: {
|
||||
duration_ms: durationMs,
|
||||
patch_id: selectedPatch.id,
|
||||
channels: { [absoluteChannel]: value },
|
||||
precedence: { [absoluteChannel]: selectedChannel.precedence }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>Effekter og chasers</h2>
|
||||
<p className="muted">
|
||||
Beat-flash kan nu følge BPM live. Vælg en patched fixture og en kanal som fx dimmer,
|
||||
strobe eller farve, gem effekten og trig den for at lade lampen pulse i takt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="workspace-grid">
|
||||
<form className="status-card section-stack" onSubmit={handleSubmit}>
|
||||
<h3>Ny effekt</h3>
|
||||
{patches.length === 0 ? (
|
||||
<div className="muted">Du skal patch’e mindst én lampe før du kan bygge en effekt.</div>
|
||||
) : null}
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">Navn</span>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Slug</span>
|
||||
<input value={slug} onChange={(event) => setSlug(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Fixture</span>
|
||||
<select value={patchId} onChange={(event) => setPatchId(Number(event.target.value))}>
|
||||
{patches.map((patch) => (
|
||||
<option key={patch.id} value={patch.id}>
|
||||
{patch.name} · {patch.manufacturer} / {patch.model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Kanal</span>
|
||||
<select value={channelIndex} onChange={(event) => setChannelIndex(Number(event.target.value))}>
|
||||
{(selectedPatch?.channels ?? []).map((channel) => (
|
||||
<option key={channel.index} value={channel.index}>
|
||||
{channel.display_name} (DMX {selectedPatch!.start_address + channel.index - 1})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Værdi</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={255}
|
||||
value={value}
|
||||
onChange={(event) => setValue(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Varighed (ms)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
max={30000}
|
||||
value={durationMs}
|
||||
onChange={(event) => setDurationMs(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="submit" disabled={patches.length === 0}>
|
||||
Gem effekt
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<article className="status-card section-stack">
|
||||
<h3>Aktuelt target</h3>
|
||||
{selectedPatch && selectedChannel ? (
|
||||
<>
|
||||
<div className="muted">
|
||||
{selectedPatch.name} · {selectedPatch.mode_key} · {selectedPatch.start_address}-{selectedPatch.end_address}
|
||||
</div>
|
||||
<div className="event-item">
|
||||
<strong>{selectedChannel.display_name}</strong>
|
||||
<div className="muted">
|
||||
Kanal {selectedChannel.index} · DMX {selectedPatch.start_address + selectedChannel.index - 1} ·{" "}
|
||||
{selectedChannel.precedence.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="muted">Vælg først en patched fixture.</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="effect-stack">
|
||||
{effects.map((effect) => (
|
||||
<div key={String(effect.slug)} className="effect-row">
|
||||
<article className="status-card">
|
||||
<span className="status-label">{String(effect.effect_type)}</span>
|
||||
<h3>{String(effect.name)}</h3>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => onTrigger(String(effect.slug))}>
|
||||
Trigger effekt
|
||||
</button>
|
||||
<button type="button" onClick={() => onStop(String(effect.slug))}>
|
||||
Stop effekt
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<article className="status-card">
|
||||
<span className="status-label">Indstillinger</span>
|
||||
<div className="muted">{describeEffect(effect)}</div>
|
||||
<div className="event-item">
|
||||
<strong>Slug</strong>
|
||||
<div className="muted">{String(effect.slug)}</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ChangeEvent, FormEvent, useMemo, useState } from "react";
|
||||
|
||||
import { FixturePreview, FixtureSummary } from "../lib/api";
|
||||
|
||||
type FixturesPageProps = {
|
||||
fixtures: FixtureSummary[];
|
||||
results: Array<{ fixture_key: string; cached: boolean }>;
|
||||
onSearch: (query: string) => Promise<void>;
|
||||
onPreview: (manufacturerKey: string, fixtureKey: string) => Promise<FixturePreview>;
|
||||
onImport: (manufacturerKey: string, fixtureKey: string) => Promise<FixtureSummary>;
|
||||
onImportFile: (payload: Record<string, unknown>) => Promise<FixtureSummary>;
|
||||
onConfigureFixture: (fixtureId: number) => void;
|
||||
};
|
||||
|
||||
export function FixturesPage({
|
||||
fixtures,
|
||||
results,
|
||||
onSearch,
|
||||
onPreview,
|
||||
onImport,
|
||||
onImportFile,
|
||||
onConfigureFixture
|
||||
}: FixturesPageProps) {
|
||||
const [query, setQuery] = useState("showtec phantom");
|
||||
const [preview, setPreview] = useState<FixturePreview | null>(null);
|
||||
const [selectedMode, setSelectedMode] = useState("");
|
||||
const [filePayload, setFilePayload] = useState("");
|
||||
const [manufacturerName, setManufacturerName] = useState("");
|
||||
const [manufacturerKey, setManufacturerKey] = useState("");
|
||||
const [fixtureKey, setFixtureKey] = useState("");
|
||||
const [fileStatus, setFileStatus] = useState<string | null>(null);
|
||||
const [searchStatus, setSearchStatus] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await onSearch(query);
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const text = await file.text();
|
||||
setFilePayload(text);
|
||||
setFileStatus(`Fil indlæst: ${file.name}`);
|
||||
if (!fixtureKey) {
|
||||
setFixtureKey(file.name.replace(/\.json$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileImport(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const parsed = JSON.parse(filePayload) as Record<string, unknown>;
|
||||
const sourceValue = parsed.source;
|
||||
const source =
|
||||
sourceValue && typeof sourceValue === "object" && !Array.isArray(sourceValue)
|
||||
? { ...(sourceValue as Record<string, unknown>) }
|
||||
: {};
|
||||
if (manufacturerKey.trim()) {
|
||||
source.manufacturer_key = manufacturerKey.trim();
|
||||
}
|
||||
if (fixtureKey.trim()) {
|
||||
source.fixture_key = fixtureKey.trim();
|
||||
}
|
||||
if (Object.keys(source).length > 0) {
|
||||
parsed.source = source;
|
||||
}
|
||||
if (manufacturerName.trim() && !parsed.manufacturer) {
|
||||
parsed.manufacturer = manufacturerName.trim();
|
||||
}
|
||||
const imported = await onImportFile(parsed);
|
||||
setPreview(imported);
|
||||
setSelectedMode(imported.modes[0]?.key ?? "");
|
||||
setFileStatus(`Fixture importeret: ${imported.manufacturer} / ${imported.model}`);
|
||||
onConfigureFixture(imported.id);
|
||||
} catch {
|
||||
setFileStatus("Kunne ikke importere fixture-filen. Kontroller at JSON-indholdet er gyldigt.");
|
||||
}
|
||||
}
|
||||
|
||||
const activeMode = useMemo(
|
||||
() => preview?.modes.find((mode) => mode.key === selectedMode) ?? preview?.modes[0] ?? null,
|
||||
[preview, selectedMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>Fixtures og OFL</h2>
|
||||
<p className="muted">
|
||||
Søg i OFL direkte fra UI, behold importen lokalt og behold offline fallback via cache.
|
||||
</p>
|
||||
<p className="muted">
|
||||
Når en fixture er importeret, kan den sendes direkte videre til patch-konfiguration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="form-grid" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
<span className="field-label">Søgetekst</span>
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<button type="submit">Søg i OFL</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form className="form-grid" onSubmit={handleFileImport}>
|
||||
<label>
|
||||
<span className="field-label">Fixture-fil (.json)</span>
|
||||
<input type="file" accept=".json,application/json" onChange={handleFileSelect} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Producentnavn</span>
|
||||
<input
|
||||
value={manufacturerName}
|
||||
onChange={(event) => setManufacturerName(event.target.value)}
|
||||
placeholder="Fx Eurolite"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Producent-key</span>
|
||||
<input
|
||||
value={manufacturerKey}
|
||||
onChange={(event) => setManufacturerKey(event.target.value)}
|
||||
placeholder="fx eurolite"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Fixture-key</span>
|
||||
<input
|
||||
value={fixtureKey}
|
||||
onChange={(event) => setFixtureKey(event.target.value)}
|
||||
placeholder="fx led-bar-3-hcl-bar"
|
||||
/>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<span className="field-label">JSON-indhold</span>
|
||||
<textarea
|
||||
rows={10}
|
||||
value={filePayload}
|
||||
onChange={(event) => setFilePayload(event.target.value)}
|
||||
placeholder="Indsæt eller indlæs en fixture JSON-fil her"
|
||||
/>
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<button type="submit" disabled={!filePayload.trim()}>
|
||||
Importér fixture-fil
|
||||
</button>
|
||||
</div>
|
||||
{fileStatus ? (
|
||||
<article className="event-item full-span">
|
||||
<div className="muted">{fileStatus}</div>
|
||||
</article>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="fixture-grid">
|
||||
{results.map((result) => {
|
||||
const [manufacturerKey, fixtureKey] = result.fixture_key.split("/");
|
||||
return (
|
||||
<article key={result.fixture_key} className="status-card">
|
||||
<span className="status-label">{result.cached ? "Lokal cache" : "OFL"}</span>
|
||||
<h3>{result.fixture_key}</h3>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const nextPreview = await onPreview(manufacturerKey, fixtureKey);
|
||||
setPreview(nextPreview);
|
||||
setSelectedMode(nextPreview.modes[0]?.key ?? "");
|
||||
setSearchStatus(null);
|
||||
} catch {
|
||||
setSearchStatus("Kunne ikke hente preview fra OFL.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Vis preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const imported = await onImport(manufacturerKey, fixtureKey);
|
||||
setPreview(imported);
|
||||
setSelectedMode(imported.modes[0]?.key ?? "");
|
||||
setSearchStatus(`Fixture importeret: ${imported.manufacturer} / ${imported.model}`);
|
||||
onConfigureFixture(imported.id);
|
||||
} catch {
|
||||
setSearchStatus("Import fra OFL fejlede.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Importér
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{searchStatus ? (
|
||||
<article className="event-item">
|
||||
<div className="muted">{searchStatus}</div>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<article className="status-card">
|
||||
<span className="status-label">Fixture preview</span>
|
||||
<h3>
|
||||
{preview.manufacturer} / {preview.model}
|
||||
</h3>
|
||||
<div className="muted">Schema: {preview.schema_version}</div>
|
||||
<div className="muted">Kategorier: {preview.categories.join(", ") || "Ingen"}</div>
|
||||
<label>
|
||||
<span className="field-label">Modevalg</span>
|
||||
<select value={selectedMode} onChange={(event) => setSelectedMode(event.target.value)}>
|
||||
{preview.modes.map((mode) => (
|
||||
<option key={mode.key} value={mode.key}>
|
||||
{mode.key} ({mode.channel_count} kanaler)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{activeMode ? (
|
||||
<div className="event-list">
|
||||
{activeMode.channels.map((channel) => (
|
||||
<div key={`${activeMode.key}-${channel.index}`} className="event-item">
|
||||
<strong>
|
||||
Kanal {channel.index}: {channel.display_name}
|
||||
</strong>
|
||||
<div className="muted">
|
||||
Key: {channel.key} · Precedence: {channel.precedence.toUpperCase()} ·
|
||||
Opløsning: {channel.resolution}-bit
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
<div className="card-grid">
|
||||
{fixtures.map((fixture) => (
|
||||
<article key={fixture.id} className="status-card">
|
||||
<span className="status-label">{String(fixture.manufacturer ?? "Ukendt producent")}</span>
|
||||
<h3>{String(fixture.model ?? "Ukendt model")}</h3>
|
||||
<div className="muted">
|
||||
Modes: {Array.isArray(fixture.modes) ? fixture.modes.length : 0} · Kilde:{" "}
|
||||
{fixture.source.manufacturer_key ?? "lokal"} / {fixture.source.fixture_key ?? fixture.slug}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => onConfigureFixture(fixture.id)}>
|
||||
Konfigurér i patch
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { LiveMixerItem, SystemStatus } from "../lib/api";
|
||||
|
||||
type LiveDeskPageProps = {
|
||||
status: SystemStatus | null;
|
||||
mixerItems: LiveMixerItem[];
|
||||
onBlackout: () => Promise<void>;
|
||||
onReleaseBlackout: () => Promise<void>;
|
||||
onSetChannel: (item: LiveMixerItem, channelIndex: number, value: number) => Promise<void>;
|
||||
onClearFixture: (item: LiveMixerItem) => Promise<void>;
|
||||
};
|
||||
|
||||
export function LiveDeskPage({
|
||||
status,
|
||||
mixerItems,
|
||||
onBlackout,
|
||||
onReleaseBlackout,
|
||||
onSetChannel,
|
||||
onClearFixture
|
||||
}: LiveDeskPageProps) {
|
||||
const [localValues, setLocalValues] = useState<Record<string, number>>({});
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(mixerItems[0]?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextValues: Record<string, number> = {};
|
||||
for (const item of mixerItems) {
|
||||
for (const channel of item.channels) {
|
||||
nextValues[`${item.id}:${channel.index}`] = channel.value;
|
||||
}
|
||||
}
|
||||
setLocalValues(nextValues);
|
||||
}, [mixerItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mixerItems.length === 0) {
|
||||
setSelectedItemId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedItemId === null || !mixerItems.some((item) => item.id === selectedItemId)) {
|
||||
setSelectedItemId(mixerItems[0].id);
|
||||
}
|
||||
}, [mixerItems, selectedItemId]);
|
||||
|
||||
const selectedFixture = useMemo(
|
||||
() => mixerItems.find((item) => item.id === selectedItemId) ?? null,
|
||||
[mixerItems, selectedItemId]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div className="live-desk-topbar">
|
||||
<div className="live-desk-topbar-copy">
|
||||
<h2>Live Desk</h2>
|
||||
<p className="muted">
|
||||
Manuel mixer til patched fixtures med hurtig adgang til lamper, niveauer og blackout.
|
||||
</p>
|
||||
</div>
|
||||
<div className="live-desk-topbar-actions">
|
||||
<button type="button" onClick={onBlackout}>Blackout</button>
|
||||
<button type="button" onClick={onReleaseBlackout}>Release blackout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className="status-card summary-panel">
|
||||
<div className="compact-stat-bar dense" aria-label="Live Desk status">
|
||||
<article className="compact-stat-pill">
|
||||
<span className="status-label">Master</span>
|
||||
<strong>{status?.engine.master ?? 0}</strong>
|
||||
</article>
|
||||
<article className="compact-stat-pill">
|
||||
<span className="status-label">FPS</span>
|
||||
<strong>{status?.engine.fps ?? 0}</strong>
|
||||
</article>
|
||||
<article className="compact-stat-pill">
|
||||
<span className="status-label">Output</span>
|
||||
<strong>{status?.engine.blackout ? "Blackout" : "Live"}</strong>
|
||||
</article>
|
||||
<article className="compact-stat-pill">
|
||||
<span className="status-label">Enheder</span>
|
||||
<strong>{mixerItems.length}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{mixerItems.length === 0 ? (
|
||||
<article className="event-item">
|
||||
<strong>Ingen live-enheder</strong>
|
||||
<div className="muted">
|
||||
Gå til Patch og tilføj dine lamper først, eller opret HA-mappings i Settings. Derefter
|
||||
vises de her som en samlet live-mixer for både fixtures og virtuelle HA-enheder.
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
<div className="live-desk-layout">
|
||||
<aside className="status-card live-fixture-list">
|
||||
<div className="live-fixture-list-header">
|
||||
<span className="status-label">Enheder i brug</span>
|
||||
<strong>{mixerItems.length} items</strong>
|
||||
</div>
|
||||
<div className="live-fixture-items">
|
||||
{mixerItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`live-fixture-button ${selectedItemId === item.id ? "active" : ""}`}
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
>
|
||||
<span className="live-fixture-button-title">{item.name}</span>
|
||||
<span className="live-fixture-button-meta">
|
||||
U{item.universe} · {item.start_address}-{item.end_address} · {item.mode_key}
|
||||
</span>
|
||||
<span className="live-fixture-button-meta">
|
||||
{item.source_type === "home_assistant"
|
||||
? `${item.entity_id ?? "HA entity"} · ${item.status ?? "ukendt"}`
|
||||
: "Patched fixture"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{selectedFixture ? (
|
||||
<article className="status-card section-stack">
|
||||
<div className="fixture-header">
|
||||
<div>
|
||||
<span className="status-label">Universe {selectedFixture.universe}</span>
|
||||
<h3>{selectedFixture.name}</h3>
|
||||
<div className="muted">
|
||||
{selectedFixture.manufacturer} / {selectedFixture.model} · {selectedFixture.mode_key} ·{" "}
|
||||
{selectedFixture.start_address}-{selectedFixture.end_address}
|
||||
</div>
|
||||
{selectedFixture.source_type === "home_assistant" ? (
|
||||
<div className="muted">
|
||||
{selectedFixture.entity_id ?? "Ukendt entity"} · Senest sendt:{" "}
|
||||
{selectedFixture.last_sent_summary ?? "ingen"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => onClearFixture(selectedFixture)}>
|
||||
{selectedFixture.source_type === "home_assistant" ? "Nulstil HA-enhed" : "Nulstil fixture"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mixer-channel-list compact">
|
||||
{selectedFixture.channels.map((channel) => {
|
||||
const key = `${selectedFixture.id}:${channel.index}`;
|
||||
const currentValue = localValues[key] ?? channel.value;
|
||||
return (
|
||||
<label key={key} className="channel-slider-row">
|
||||
<div className="channel-slider-row-label">
|
||||
<strong>{channel.display_name}</strong>
|
||||
<span className="muted">DMX {channel.absolute_channel}</span>
|
||||
</div>
|
||||
<div className="channel-slider-row-track">
|
||||
<span className="channel-slider-row-key muted">{channel.key}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
value={currentValue}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
setLocalValues((previous) => ({ ...previous, [key]: next }));
|
||||
}}
|
||||
onMouseUp={async () => {
|
||||
await onSetChannel(selectedFixture, channel.index, currentValue);
|
||||
}}
|
||||
onTouchEnd={async () => {
|
||||
await onSetChannel(selectedFixture, channel.index, currentValue);
|
||||
}}
|
||||
/>
|
||||
<span className="channel-value-chip">{currentValue}</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { MidiBridgeStatus, MidiBridgeToken, MidiLearnState, MidiMapping, SceneItem } from "../lib/api";
|
||||
|
||||
type MidiPageProps = {
|
||||
bridges: MidiBridgeStatus[];
|
||||
mappings: MidiMapping[];
|
||||
tokens: MidiBridgeToken[];
|
||||
learnState: MidiLearnState | null;
|
||||
scenes: SceneItem[];
|
||||
effects: Array<Record<string, unknown>>;
|
||||
onSaveMapping: (payload: Record<string, unknown>, mappingId: number | null) => Promise<string>;
|
||||
onDeleteMapping: (mappingId: number) => Promise<void>;
|
||||
onTestMapping: (mappingId: number) => Promise<string>;
|
||||
onCreateToken: (payload: { label: string; bridge_id: string | null; scopes: string[] }) => Promise<MidiBridgeToken>;
|
||||
onRevokeToken: (tokenId: number) => Promise<void>;
|
||||
onStartLearn: (timeoutSeconds: number, allowPassthrough: boolean) => Promise<string>;
|
||||
onCancelLearn: () => Promise<string>;
|
||||
};
|
||||
|
||||
type MidiAction =
|
||||
| "activate_scene"
|
||||
| "deactivate_scene"
|
||||
| "toggle_scene"
|
||||
| "flash_scene"
|
||||
| "blackout_on"
|
||||
| "blackout_off"
|
||||
| "blackout_toggle"
|
||||
| "set_master_dimmer"
|
||||
| "set_scene_intensity"
|
||||
| "start_effect"
|
||||
| "stop_effect";
|
||||
|
||||
type MidiMode = "trigger" | "toggle" | "hold" | "flash" | "continuous";
|
||||
type MidiMessageType = "note_on" | "note_off" | "control_change" | "program_change";
|
||||
|
||||
const sceneActions: MidiAction[] = [
|
||||
"activate_scene",
|
||||
"deactivate_scene",
|
||||
"toggle_scene",
|
||||
"flash_scene",
|
||||
"set_scene_intensity"
|
||||
];
|
||||
const effectActions: MidiAction[] = ["start_effect", "stop_effect"];
|
||||
|
||||
function targetTypeForAction(action: MidiAction): "scene" | "effect" | "system" | "global" {
|
||||
if (sceneActions.includes(action)) {
|
||||
return "scene";
|
||||
}
|
||||
if (effectActions.includes(action)) {
|
||||
return "effect";
|
||||
}
|
||||
if (action === "set_master_dimmer") {
|
||||
return "global";
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function defaultModeForAction(action: MidiAction): MidiMode {
|
||||
if (action === "set_master_dimmer" || action === "set_scene_intensity") {
|
||||
return "continuous";
|
||||
}
|
||||
if (action === "flash_scene") {
|
||||
return "hold";
|
||||
}
|
||||
return "trigger";
|
||||
}
|
||||
|
||||
export function MidiPage({
|
||||
bridges,
|
||||
mappings,
|
||||
tokens,
|
||||
learnState,
|
||||
scenes,
|
||||
effects,
|
||||
onSaveMapping,
|
||||
onDeleteMapping,
|
||||
onTestMapping,
|
||||
onCreateToken,
|
||||
onRevokeToken,
|
||||
onStartLearn,
|
||||
onCancelLearn
|
||||
}: MidiPageProps) {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [bridgeId, setBridgeId] = useState("*");
|
||||
const [deviceName, setDeviceName] = useState("*");
|
||||
const [messageType, setMessageType] = useState<MidiMessageType>("note_on");
|
||||
const [channel, setChannel] = useState<number>(0);
|
||||
const [number, setNumber] = useState<number>(36);
|
||||
const [action, setAction] = useState<MidiAction>("activate_scene");
|
||||
const [mode, setMode] = useState<MidiMode>("trigger");
|
||||
const [targetId, setTargetId] = useState<string>("");
|
||||
const [minimumValue, setMinimumValue] = useState<number>(0);
|
||||
const [maximumValue, setMaximumValue] = useState<number>(127);
|
||||
const [enabled, setEnabled] = useState<boolean>(true);
|
||||
|
||||
const [tokenLabel, setTokenLabel] = useState("");
|
||||
const [tokenBridgeId, setTokenBridgeId] = useState("");
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
|
||||
const bridgeChoices = useMemo(
|
||||
() => ["*", ...bridges.map((bridge) => bridge.bridge_id)],
|
||||
[bridges]
|
||||
);
|
||||
const effectChoices = useMemo(
|
||||
() =>
|
||||
effects
|
||||
.map((effect) => ({
|
||||
slug: String(effect.slug ?? ""),
|
||||
name: String(effect.name ?? effect.slug ?? "Effect")
|
||||
}))
|
||||
.filter((effect) => effect.slug),
|
||||
[effects]
|
||||
);
|
||||
const currentTargetType = targetTypeForAction(action);
|
||||
|
||||
function resetForm() {
|
||||
setEditingId(null);
|
||||
setName("");
|
||||
setBridgeId("*");
|
||||
setDeviceName("*");
|
||||
setMessageType("note_on");
|
||||
setChannel(0);
|
||||
setNumber(36);
|
||||
setAction("activate_scene");
|
||||
setMode("trigger");
|
||||
setTargetId("");
|
||||
setMinimumValue(0);
|
||||
setMaximumValue(127);
|
||||
setEnabled(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>MIDI bridge og mappings</h2>
|
||||
<p className="muted">
|
||||
Eksterne USB-MIDI-controllere kan nu kobles på via den separate Python-bridge. Mappings kalder den
|
||||
eksisterende scene-, blackout- og effektmotor i backend og sender aldrig DMX direkte.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<article className="status-card summary-panel">
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Bridges</span>
|
||||
<strong>{bridges.length}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Mappings</span>
|
||||
<strong>{mappings.length}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Tokens</span>
|
||||
<strong>{tokens.filter((token) => !token.revoked_at).length}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Learn</span>
|
||||
<strong>{learnState?.active ? "Armeret" : "Klar"}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="workspace-grid">
|
||||
<article className="status-card section-stack">
|
||||
<h3>MIDI Learn</h3>
|
||||
<div className="muted">
|
||||
Armér læring, tryk en fysisk knap eller bevæg en fader, og brug derefter den fangede besked til
|
||||
mappingformularen.
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={async () => setMessage(await onStartLearn(15, false))}>
|
||||
Start learn
|
||||
</button>
|
||||
<button type="button" onClick={async () => setMessage(await onCancelLearn())}>
|
||||
Annullér learn
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!learnState?.captured_event}
|
||||
onClick={() => {
|
||||
const captured = learnState?.captured_event;
|
||||
if (!captured) {
|
||||
return;
|
||||
}
|
||||
setBridgeId(captured.bridge_id || "*");
|
||||
setDeviceName(captured.device || "*");
|
||||
setMessageType((captured.message.type as MidiMessageType) ?? "note_on");
|
||||
setChannel(captured.message.channel ?? 0);
|
||||
setNumber(captured.message.number ?? 36);
|
||||
}}
|
||||
>
|
||||
Brug seneste learn
|
||||
</button>
|
||||
</div>
|
||||
<div className="muted">
|
||||
Status: {learnState?.active ? "venter på næste MIDI-event" : "ikke aktiv"} · Timeout:{" "}
|
||||
{learnState?.expires_at ? new Date(learnState.expires_at).toLocaleTimeString("da-DK") : "ingen"}
|
||||
</div>
|
||||
{learnState?.captured_event ? (
|
||||
<div className="event-item">
|
||||
<strong>{learnState.captured_event.device}</strong>
|
||||
<div className="muted">
|
||||
{learnState.captured_event.message.type} · kanal {learnState.captured_event.message.channel} · nummer{" "}
|
||||
{learnState.captured_event.message.number} · værdi {learnState.captured_event.message.value}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="muted">Ingen learn-event fanget endnu.</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="status-card section-stack">
|
||||
<h3>Bridge-tokens</h3>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">Label</span>
|
||||
<input value={tokenLabel} onChange={(event) => setTokenLabel(event.target.value)} placeholder="fx APC mini bar" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Lås til bridge-id</span>
|
||||
<input
|
||||
value={tokenBridgeId}
|
||||
onChange={(event) => setTokenBridgeId(event.target.value)}
|
||||
placeholder="fx bar-laptop"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const created = await onCreateToken({
|
||||
label: tokenLabel || "MIDI bridge",
|
||||
bridge_id: tokenBridgeId.trim() || null,
|
||||
scopes: ["midi:connect", "midi:events", "midi:heartbeat"]
|
||||
});
|
||||
setCreatedToken(created.token ?? null);
|
||||
setTokenLabel("");
|
||||
setTokenBridgeId("");
|
||||
setMessage("MIDI-token oprettet.");
|
||||
}}
|
||||
>
|
||||
Opret token
|
||||
</button>
|
||||
</div>
|
||||
{createdToken ? <div className="event-item">Kopiér token nu: <strong>{createdToken}</strong></div> : null}
|
||||
<div className="event-list">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="event-item">
|
||||
<strong>{token.label}</strong>
|
||||
<div className="muted">
|
||||
Preview: {token.token_preview} · Bridge: {token.bridge_id ?? "alle"} · Scopes: {token.scopes.join(", ")}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Oprettet: {new Date(token.created_at).toLocaleString("da-DK")} · Senest brugt:{" "}
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleString("da-DK") : "aldrig"} ·
|
||||
Status: {token.revoked_at ? "tilbagekaldt" : "aktiv"}
|
||||
</div>
|
||||
{!token.revoked_at ? (
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await onRevokeToken(token.id);
|
||||
setMessage("MIDI-token tilbagekaldt.");
|
||||
}}
|
||||
>
|
||||
Tilbagekald token
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="workspace-grid">
|
||||
<article className="status-card section-stack">
|
||||
<h3>Ny MIDI-mapping</h3>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">Navn</span>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Bridge-id</span>
|
||||
<select value={bridgeId} onChange={(event) => setBridgeId(event.target.value)}>
|
||||
{bridgeChoices.map((choice) => (
|
||||
<option key={choice} value={choice}>
|
||||
{choice}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Device match</span>
|
||||
<input value={deviceName} onChange={(event) => setDeviceName(event.target.value)} placeholder="fx USB*" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Message type</span>
|
||||
<select value={messageType} onChange={(event) => setMessageType(event.target.value as MidiMessageType)}>
|
||||
<option value="note_on">note_on</option>
|
||||
<option value="note_off">note_off</option>
|
||||
<option value="control_change">control_change</option>
|
||||
<option value="program_change">program_change</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Kanal</span>
|
||||
<input type="number" min={0} max={15} value={channel} onChange={(event) => setChannel(Number(event.target.value || 0))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Nummer</span>
|
||||
<input type="number" min={0} max={127} value={number} onChange={(event) => setNumber(Number(event.target.value || 0))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Action</span>
|
||||
<select
|
||||
value={action}
|
||||
onChange={(event) => {
|
||||
const nextAction = event.target.value as MidiAction;
|
||||
setAction(nextAction);
|
||||
setMode(defaultModeForAction(nextAction));
|
||||
setTargetId("");
|
||||
}}
|
||||
>
|
||||
<option value="activate_scene">activate_scene</option>
|
||||
<option value="deactivate_scene">deactivate_scene</option>
|
||||
<option value="toggle_scene">toggle_scene</option>
|
||||
<option value="flash_scene">flash_scene</option>
|
||||
<option value="blackout_on">blackout_on</option>
|
||||
<option value="blackout_off">blackout_off</option>
|
||||
<option value="blackout_toggle">blackout_toggle</option>
|
||||
<option value="set_master_dimmer">set_master_dimmer</option>
|
||||
<option value="set_scene_intensity">set_scene_intensity</option>
|
||||
<option value="start_effect">start_effect</option>
|
||||
<option value="stop_effect">stop_effect</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Mode</span>
|
||||
<select value={mode} onChange={(event) => setMode(event.target.value as MidiMode)}>
|
||||
<option value="trigger">trigger</option>
|
||||
<option value="toggle">toggle</option>
|
||||
<option value="hold">hold</option>
|
||||
<option value="flash">flash</option>
|
||||
<option value="continuous">continuous</option>
|
||||
</select>
|
||||
</label>
|
||||
{currentTargetType === "scene" ? (
|
||||
<label className="full-span">
|
||||
<span className="field-label">Scene</span>
|
||||
<select value={targetId} onChange={(event) => setTargetId(event.target.value)}>
|
||||
<option value="">Vælg scene</option>
|
||||
{scenes.map((scene) => (
|
||||
<option key={scene.id} value={scene.slug}>
|
||||
{scene.name} ({scene.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{currentTargetType === "effect" ? (
|
||||
<label className="full-span">
|
||||
<span className="field-label">Effect</span>
|
||||
<select value={targetId} onChange={(event) => setTargetId(event.target.value)}>
|
||||
<option value="">Vælg effect</option>
|
||||
{effectChoices.map((effect) => (
|
||||
<option key={effect.slug} value={effect.slug}>
|
||||
{effect.name} ({effect.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
<span className="field-label">Minimumsværdi</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={127}
|
||||
value={minimumValue}
|
||||
onChange={(event) => setMinimumValue(Number(event.target.value || 0))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Maksimumsværdi</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={127}
|
||||
value={maximumValue}
|
||||
onChange={(event) => setMaximumValue(Number(event.target.value || 127))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Aktiv</span>
|
||||
<select value={enabled ? "yes" : "no"} onChange={(event) => setEnabled(event.target.value === "yes")}>
|
||||
<option value="yes">Ja</option>
|
||||
<option value="no">Nej</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setMessage(
|
||||
await onSaveMapping(
|
||||
{
|
||||
name,
|
||||
enabled,
|
||||
bridge_id: bridgeId === "*" ? null : bridgeId,
|
||||
device_name: deviceName === "*" ? null : deviceName,
|
||||
message_type: messageType,
|
||||
channel,
|
||||
number,
|
||||
action,
|
||||
target_type: currentTargetType,
|
||||
target_id: targetId || null,
|
||||
mode,
|
||||
minimum_value: minimumValue,
|
||||
maximum_value: maximumValue
|
||||
},
|
||||
editingId
|
||||
)
|
||||
);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
{editingId === null ? "Gem mapping" : "Opdatér mapping"}
|
||||
</button>
|
||||
{editingId !== null ? (
|
||||
<button type="button" onClick={resetForm}>
|
||||
Annullér redigering
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="status-card section-stack">
|
||||
<h3>Aktive bridges</h3>
|
||||
<div className="event-list">
|
||||
{bridges.map((bridge) => (
|
||||
<div key={bridge.bridge_id} className="event-item">
|
||||
<strong>{bridge.bridge_id}</strong>
|
||||
<div className="muted">
|
||||
{bridge.device_name || "Ukendt device"} · {bridge.ip_address ?? "ukendt IP"} ·
|
||||
{bridge.online ? " online" : " offline"}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Heartbeat:{" "}
|
||||
{bridge.last_heartbeat_at ? new Date(bridge.last_heartbeat_at).toLocaleString("da-DK") : "ingen"} ·
|
||||
Seneste event: {bridge.last_event_at ? new Date(bridge.last_event_at).toLocaleString("da-DK") : "ingen"}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Seneste MIDI:{" "}
|
||||
{bridge.last_event
|
||||
? `${bridge.last_event.type} ch ${bridge.last_event.channel} no ${bridge.last_event.number} val ${bridge.last_event.value}`
|
||||
: "ingen"}
|
||||
</div>
|
||||
<div className="muted">Fejl: {bridge.last_error ?? "ingen"}</div>
|
||||
</div>
|
||||
))}
|
||||
{bridges.length === 0 ? <div className="muted">Ingen bridges registreret endnu.</div> : null}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="card-grid">
|
||||
{mappings.map((mapping) => (
|
||||
<article key={mapping.id} className="status-card section-stack">
|
||||
<div className="scene-card-header">
|
||||
<div>
|
||||
<span className="status-label">{mapping.message_type} #{mapping.number}</span>
|
||||
<h3>{mapping.name}</h3>
|
||||
</div>
|
||||
<span className={`pill ${mapping.active ? "success" : "info"}`}>
|
||||
{mapping.active ? "Aktiv" : mapping.enabled ? "Klar" : "Deaktiv"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="muted">
|
||||
Bridge: {mapping.bridge_id ?? "*"} · Device: {mapping.device_name ?? "*"} · Kanal:{" "}
|
||||
{mapping.channel ?? "*"} · Mode: {mapping.mode}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Action: {mapping.action} · Target: {mapping.target_id ?? "ingen"} · Range: {mapping.minimum_value}-
|
||||
{mapping.maximum_value}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingId(mapping.id);
|
||||
setName(mapping.name);
|
||||
setBridgeId(mapping.bridge_id ?? "*");
|
||||
setDeviceName(mapping.device_name ?? "*");
|
||||
setMessageType(mapping.message_type);
|
||||
setChannel(mapping.channel ?? 0);
|
||||
setNumber(mapping.number);
|
||||
setAction(mapping.action);
|
||||
setMode(mapping.mode);
|
||||
setTargetId(mapping.target_id ?? "");
|
||||
setMinimumValue(mapping.minimum_value);
|
||||
setMaximumValue(mapping.maximum_value);
|
||||
setEnabled(mapping.enabled);
|
||||
}}
|
||||
>
|
||||
Redigér
|
||||
</button>
|
||||
<button type="button" onClick={async () => setMessage(await onTestMapping(mapping.id))}>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await onDeleteMapping(mapping.id);
|
||||
setMessage("MIDI-mapping slettet.");
|
||||
if (editingId === mapping.id) {
|
||||
resetForm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Slet
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{message ? <div className="muted">{message}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
type MixitupPageProps = {
|
||||
queueDepth: number;
|
||||
onTestTrigger: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function MixitupPage({ queueDepth, onTestTrigger }: MixitupPageProps) {
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>MixItUp og Twitch</h2>
|
||||
<p className="muted">
|
||||
Triggerendpoint svarer med `202 Accepted` og lægger arbejdet i kø, så MixItUp ikke
|
||||
blokeres af langvarige lyseffekter.
|
||||
</p>
|
||||
</div>
|
||||
<div className="status-grid">
|
||||
<article className="status-card">
|
||||
<span className="status-label">Kødybde</span>
|
||||
<div className="status-value">{queueDepth}</div>
|
||||
</article>
|
||||
<article className="status-card">
|
||||
<span className="status-label">Eksempel-URL</span>
|
||||
<div className="metric-note">POST /api/v1/triggers/raid</div>
|
||||
</article>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button onClick={onTestTrigger}>Send testtrigger</button>
|
||||
</div>
|
||||
<div className="event-item">
|
||||
<strong>Headers</strong>
|
||||
<div className="muted">Authorization: Bearer <token></div>
|
||||
<div className="muted">Content-Type: application/json</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ChannelGrid } from "../components/ChannelGrid";
|
||||
|
||||
type MonitorPageProps = {
|
||||
values: number[];
|
||||
sources: string[];
|
||||
events: Array<{ created_at: string; category: string; level: string; message: string }>;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export function MonitorPage({ values, sources, events, updatedAt }: MonitorPageProps) {
|
||||
const activeCount = values.reduce((count, value, index) => {
|
||||
const source = sources[index] ?? "idle";
|
||||
return value > 0 || source !== "idle" ? count + 1 : count;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>DMX monitor og telemetri</h2>
|
||||
<p className="muted">
|
||||
Monitoren viser kun aktive DMX-kanaler og opdateres live fra backend. Fysisk
|
||||
signalverifikation kræver ekstern måling eller understøttet RX/RDM og er ikke påstået her.
|
||||
</p>
|
||||
</div>
|
||||
<article className="status-card summary-panel">
|
||||
<div className="compact-stat-bar dense" aria-label="DMX monitor status">
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Aktive kanaler</span>
|
||||
<strong>{activeCount}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Visning</span>
|
||||
<strong>Kun aktive</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Live</span>
|
||||
<strong>{updatedAt ? "Opdaterer" : "Afventer"}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Sidst opdateret</span>
|
||||
<strong>{updatedAt ? new Date(updatedAt).toLocaleTimeString("da-DK") : "--:--:--"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<ChannelGrid values={values} sources={sources} />
|
||||
<div className="event-list">
|
||||
{events.map((event, index) => (
|
||||
<div key={`${event.created_at}-${index}`} className="event-item">
|
||||
<strong>
|
||||
{event.category} · {event.level}
|
||||
</strong>
|
||||
<div className="muted">{event.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { FixtureSummary, PatchItem, PatchValidation } from "../lib/api";
|
||||
|
||||
const EMPTY_MODES: FixtureSummary["modes"] = [];
|
||||
|
||||
type PatchPageProps = {
|
||||
fixtures: FixtureSummary[];
|
||||
patches: PatchItem[];
|
||||
onValidate: (payload: Record<string, unknown>) => Promise<PatchValidation>;
|
||||
onSave: (payload: Record<string, unknown>, patchId: number | null) => Promise<void>;
|
||||
onDelete: (patchId: number) => Promise<void>;
|
||||
preferredDefinitionId?: number | null;
|
||||
};
|
||||
|
||||
function buildChannelLabel(channel: {
|
||||
index: number;
|
||||
display_name: string;
|
||||
precedence: string;
|
||||
resolution: number;
|
||||
}): string {
|
||||
return `${channel.index}. ${channel.display_name} · ${channel.precedence.toUpperCase()} · ${channel.resolution}-bit`;
|
||||
}
|
||||
|
||||
function parseGroupNames(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter((item, index, array) => array.findIndex((entry) => entry.toLowerCase() === item.toLowerCase()) === index);
|
||||
}
|
||||
|
||||
export function PatchPage({
|
||||
fixtures,
|
||||
patches,
|
||||
onValidate,
|
||||
onSave,
|
||||
onDelete,
|
||||
preferredDefinitionId = null
|
||||
}: PatchPageProps) {
|
||||
const [editingPatchId, setEditingPatchId] = useState<number | null>(null);
|
||||
const [definitionId, setDefinitionId] = useState<number>(fixtures[0]?.id ?? 0);
|
||||
const [modeKey, setModeKey] = useState<string>(fixtures[0]?.modes[0]?.key ?? "");
|
||||
const [startAddress, setStartAddress] = useState<number>(1);
|
||||
const [name, setName] = useState<string>("");
|
||||
const [groupNamesText, setGroupNamesText] = useState<string>("front");
|
||||
const [positionX, setPositionX] = useState<number>(50);
|
||||
const [positionY, setPositionY] = useState<number>(50);
|
||||
const [positionZ, setPositionZ] = useState<number>(0);
|
||||
const [rotation, setRotation] = useState<number>(0);
|
||||
const [validation, setValidation] = useState<PatchValidation | null>(null);
|
||||
|
||||
const selectedFixture = useMemo(
|
||||
() => fixtures.find((fixture) => fixture.id === definitionId) ?? null,
|
||||
[definitionId, fixtures]
|
||||
);
|
||||
const availableModes = selectedFixture?.modes ?? EMPTY_MODES;
|
||||
const selectedMode = availableModes.find((mode) => mode.key === modeKey) ?? availableModes[0] ?? null;
|
||||
const existingGroups = useMemo(
|
||||
() => Array.from(new Set(patches.flatMap((patch) => patch.group_names))).sort((left, right) => left.localeCompare(right)),
|
||||
[patches]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (fixtures.length === 0) {
|
||||
setDefinitionId(0);
|
||||
setModeKey("");
|
||||
setName("");
|
||||
return;
|
||||
}
|
||||
if (!fixtures.some((fixture) => fixture.id === definitionId)) {
|
||||
const firstFixture = fixtures[0];
|
||||
setDefinitionId(firstFixture.id);
|
||||
setModeKey(firstFixture.modes[0]?.key ?? "");
|
||||
setName(`${firstFixture.manufacturer} ${firstFixture.model}`);
|
||||
}
|
||||
}, [definitionId, fixtures]);
|
||||
|
||||
useEffect(() => {
|
||||
if (preferredDefinitionId === null) {
|
||||
return;
|
||||
}
|
||||
const fixture = fixtures.find((item) => item.id === preferredDefinitionId);
|
||||
if (!fixture) {
|
||||
return;
|
||||
}
|
||||
if (definitionId === fixture.id && modeKey) {
|
||||
return;
|
||||
}
|
||||
setEditingPatchId(null);
|
||||
setDefinitionId(fixture.id);
|
||||
setModeKey(fixture.modes[0]?.key ?? "");
|
||||
setName(`${fixture.manufacturer} ${fixture.model}`);
|
||||
}, [definitionId, fixtures, modeKey, preferredDefinitionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFixture === null) {
|
||||
setModeKey("");
|
||||
return;
|
||||
}
|
||||
if (!availableModes.some((mode) => mode.key === modeKey)) {
|
||||
setModeKey(availableModes[0]?.key ?? "");
|
||||
}
|
||||
if (!name.trim() || editingPatchId === null) {
|
||||
setName((current) => current.trim() || `${selectedFixture.manufacturer} ${selectedFixture.model}`);
|
||||
}
|
||||
}, [availableModes, editingPatchId, modeKey, name, selectedFixture]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFixture === null || !modeKey) {
|
||||
setValidation(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void onValidate({
|
||||
universe: 1,
|
||||
definition_id: selectedFixture.id,
|
||||
mode_key: modeKey,
|
||||
start_address: startAddress,
|
||||
exclude_patch_id: editingPatchId,
|
||||
}).then((result) => {
|
||||
if (!cancelled) {
|
||||
setValidation(result);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [definitionId, editingPatchId, modeKey, onValidate, selectedFixture, startAddress]);
|
||||
|
||||
const selectedRange = validation
|
||||
? { start: startAddress, end: validation.end_address }
|
||||
: selectedMode
|
||||
? { start: startAddress, end: startAddress + selectedMode.channel_count - 1 }
|
||||
: null;
|
||||
|
||||
const occupiedChannels = useMemo(() => new Set(patches.flatMap((patch) => {
|
||||
if (editingPatchId !== null && patch.id === editingPatchId) {
|
||||
return [];
|
||||
}
|
||||
return Array.from({ length: patch.channel_count }, (_, index) => patch.start_address + index);
|
||||
})), [editingPatchId, patches]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (selectedFixture === null || !modeKey || !validation?.valid) {
|
||||
return;
|
||||
}
|
||||
await onSave(
|
||||
{
|
||||
universe: 1,
|
||||
name,
|
||||
definition_id: selectedFixture.id,
|
||||
mode_key: modeKey,
|
||||
start_address: startAddress,
|
||||
enabled: true,
|
||||
group_names: parseGroupNames(groupNamesText),
|
||||
position: {
|
||||
x: positionX,
|
||||
y: positionY,
|
||||
z: positionZ,
|
||||
rotation,
|
||||
}
|
||||
},
|
||||
editingPatchId
|
||||
);
|
||||
setEditingPatchId(null);
|
||||
setName(`${selectedFixture.manufacturer} ${selectedFixture.model}`);
|
||||
setGroupNamesText(existingGroups[0] ?? "front");
|
||||
setStartAddress(1);
|
||||
setPositionX(50);
|
||||
setPositionY(50);
|
||||
setPositionZ(0);
|
||||
setRotation(0);
|
||||
}
|
||||
|
||||
function handleEdit(patch: PatchItem) {
|
||||
setEditingPatchId(patch.id);
|
||||
setDefinitionId(patch.definition_id);
|
||||
setModeKey(patch.mode_key);
|
||||
setStartAddress(patch.start_address);
|
||||
setName(patch.name);
|
||||
setGroupNamesText(patch.group_names.join(", "));
|
||||
setPositionX(patch.position.x);
|
||||
setPositionY(patch.position.y);
|
||||
setPositionZ(patch.position.z);
|
||||
setRotation(patch.position.rotation);
|
||||
}
|
||||
|
||||
function handleCancelEdit() {
|
||||
setEditingPatchId(null);
|
||||
if (selectedFixture !== null) {
|
||||
setName(`${selectedFixture.manufacturer} ${selectedFixture.model}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>Patch og grupper</h2>
|
||||
<p className="muted">
|
||||
Patch fixtures med mode, adresse, grupper og sceneplacering. Fixtures med samme grupper kan styres samlet som linkede enheder i scener.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<article className="status-card summary-panel">
|
||||
<div className="compact-stat-bar dense" aria-label="Patch status">
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Fixtures</span>
|
||||
<strong>{fixtures.length}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Patchede</span>
|
||||
<strong>{patches.length}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Grupper</span>
|
||||
<strong>{existingGroups.length}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Universe</span>
|
||||
<strong>1</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{fixtures.length === 0 ? (
|
||||
<article className="event-item">
|
||||
<strong>Ingen importerede fixtures endnu</strong>
|
||||
<div className="muted">
|
||||
Importér først en fixture under <em>Fixtures</em>. Derefter kan du vælge mode, gruppe og fysisk placering her.
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
<form className="section-stack" onSubmit={handleSubmit}>
|
||||
<div className="workspace-grid patch-workspace">
|
||||
<div className="section-stack">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">Fixture</span>
|
||||
<select value={definitionId} onChange={(event) => setDefinitionId(Number(event.target.value))}>
|
||||
{fixtures.map((fixture) => (
|
||||
<option key={fixture.id} value={fixture.id}>
|
||||
{fixture.manufacturer} / {fixture.model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Mode</span>
|
||||
<select value={modeKey} onChange={(event) => setModeKey(event.target.value)}>
|
||||
{availableModes.map((mode) => (
|
||||
<option key={mode.key} value={mode.key}>
|
||||
{mode.key} ({mode.channel_count} kanaler)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Navn</span>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Startadresse</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={512}
|
||||
value={startAddress}
|
||||
onChange={(event) => setStartAddress(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<span className="field-label">Grupper / link</span>
|
||||
<input
|
||||
value={groupNamesText}
|
||||
onChange={(event) => setGroupNamesText(event.target.value)}
|
||||
placeholder="fx front, wash, synk-a"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<article className={`event-item ${validation?.valid ? "" : "wizard"}`}>
|
||||
<strong>Validering</strong>
|
||||
<div className="muted">Range: {validation?.range ?? "Vælg fixture og mode"}</div>
|
||||
<div className="muted">
|
||||
Slutadresse: {validation?.end_address ?? (selectedRange?.end ?? "-")} · Kanaler:{" "}
|
||||
{validation?.channel_count ?? (selectedMode?.channel_count ?? "-")}
|
||||
</div>
|
||||
{validation?.conflicts.length ? (
|
||||
<div className="event-list">
|
||||
{validation.conflicts.map((conflict, index) => (
|
||||
<div key={`${conflict.type}-${index}`} className="muted">
|
||||
{conflict.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="muted">Ingen overlap fundet.</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article className="status-card section-stack">
|
||||
<span className="status-label">Placering</span>
|
||||
<div className="placement-preview">
|
||||
<div className="placement-preview-stage">
|
||||
<div
|
||||
className="placement-preview-marker"
|
||||
style={{
|
||||
left: `${positionX}%`,
|
||||
top: `${positionY}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">X</span>
|
||||
<input type="number" min={0} max={100} value={positionX} onChange={(event) => setPositionX(Number(event.target.value))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Y</span>
|
||||
<input type="number" min={0} max={100} value={positionY} onChange={(event) => setPositionY(Number(event.target.value))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Højde</span>
|
||||
<input type="number" min={0} max={100} value={positionZ} onChange={(event) => setPositionZ(Number(event.target.value))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Rotation</span>
|
||||
<input type="number" min={0} max={360} value={rotation} onChange={(event) => setRotation(Number(event.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="button-row">
|
||||
<button type="submit" disabled={!validation?.valid || selectedFixture === null}>
|
||||
{editingPatchId === null ? "Gem patch" : "Opdatér patch"}
|
||||
</button>
|
||||
{editingPatchId !== null ? (
|
||||
<button type="button" onClick={handleCancelEdit}>
|
||||
Annullér redigering
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{existingGroups.length > 0 ? (
|
||||
<article className="status-card">
|
||||
<span className="status-label">Eksisterende grupper</span>
|
||||
<div className="pill-row">
|
||||
{existingGroups.map((group) => (
|
||||
<span key={group} className="pill info">{group}</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{selectedMode ? (
|
||||
<article className="status-card">
|
||||
<span className="status-label">Modekanaler</span>
|
||||
<h3>{selectedFixture?.manufacturer} / {selectedFixture?.model}</h3>
|
||||
<div className="event-list">
|
||||
{selectedMode.channels.map((channel) => (
|
||||
<div key={`${selectedMode.key}-${channel.index}`} className="event-item">
|
||||
{buildChannelLabel(channel)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
<article className="status-card">
|
||||
<span className="status-label">Universe-grid</span>
|
||||
<div className="channel-grid">
|
||||
{Array.from({ length: 512 }, (_, index) => {
|
||||
const address = index + 1;
|
||||
const inSelectedRange = selectedRange !== null && address >= selectedRange.start && address <= selectedRange.end;
|
||||
const occupied = occupiedChannels.has(address);
|
||||
const classNames = ["channel-cell"];
|
||||
if (occupied) {
|
||||
classNames.push("occupied");
|
||||
}
|
||||
if (inSelectedRange) {
|
||||
classNames.push("selected");
|
||||
}
|
||||
if (occupied && inSelectedRange) {
|
||||
classNames.push("conflict");
|
||||
}
|
||||
return (
|
||||
<div key={address} className={classNames.join(" ")}>
|
||||
<strong>{address}</strong>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="card-grid">
|
||||
{patches.map((patch) => (
|
||||
<article key={patch.id} className="status-card">
|
||||
<span className="status-label">Universe {patch.universe}</span>
|
||||
<h3>{patch.name}</h3>
|
||||
<div className="muted">
|
||||
{patch.manufacturer} / {patch.model}
|
||||
</div>
|
||||
<div className="muted">
|
||||
{patch.mode_key} · {patch.start_address}-{patch.end_address}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Grupper: {patch.group_names.length > 0 ? patch.group_names.join(", ") : "ingen"}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Placering: x {patch.position.x}% · y {patch.position.y}% · rot {patch.position.rotation}°
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => handleEdit(patch)}>
|
||||
Redigér
|
||||
</button>
|
||||
<button type="button" onClick={() => onDelete(patch.id)}>
|
||||
Slet
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { PatchItem } from "../lib/api";
|
||||
|
||||
type PlacementPageProps = {
|
||||
patches: PatchItem[];
|
||||
onSavePlacement: (
|
||||
patchId: number,
|
||||
position: { x: number; y: number; z: number; rotation: number }
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function PlacementPage({ patches, onSavePlacement }: PlacementPageProps) {
|
||||
const [selectedPatchId, setSelectedPatchId] = useState<number | null>(patches[0]?.id ?? null);
|
||||
const [draftPosition, setDraftPosition] = useState<{ x: number; y: number; z: number; rotation: number }>({
|
||||
x: 50,
|
||||
y: 50,
|
||||
z: 0,
|
||||
rotation: 0
|
||||
});
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const selectedPatch = useMemo(
|
||||
() => patches.find((patch) => patch.id === selectedPatchId) ?? patches[0] ?? null,
|
||||
[patches, selectedPatchId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (patches.length > 0 && (selectedPatchId === null || !patches.some((patch) => patch.id === selectedPatchId))) {
|
||||
setSelectedPatchId(patches[0].id);
|
||||
}
|
||||
}, [patches, selectedPatchId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPatch) {
|
||||
return;
|
||||
}
|
||||
setDraftPosition(selectedPatch.position);
|
||||
setSaveState("idle");
|
||||
}, [selectedPatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unchanged =
|
||||
draftPosition.x === selectedPatch.position.x &&
|
||||
draftPosition.y === selectedPatch.position.y &&
|
||||
draftPosition.z === selectedPatch.position.z &&
|
||||
draftPosition.rotation === selectedPatch.position.rotation;
|
||||
|
||||
if (unchanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveState("saving");
|
||||
const timeout = window.setTimeout(async () => {
|
||||
await onSavePlacement(selectedPatch.id, draftPosition);
|
||||
setSaveState("saved");
|
||||
window.setTimeout(() => setSaveState("idle"), 900);
|
||||
}, 220);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [draftPosition, onSavePlacement, selectedPatch]);
|
||||
|
||||
function updateDraft(key: "x" | "y" | "z" | "rotation", value: number) {
|
||||
setDraftPosition((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>Placerings-view</h2>
|
||||
<p className="muted">
|
||||
Se patched fixtures som et fysisk layout. Placering bruges videre i scener og er klar til senere visualisering og position-baserede effekter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{patches.length === 0 ? (
|
||||
<article className="event-item">
|
||||
<strong>Ingen patched fixtures</strong>
|
||||
<div className="muted">Patch mindst én lampe først, så kan du placere den her.</div>
|
||||
</article>
|
||||
) : (
|
||||
<div className="workspace-grid placement-workspace">
|
||||
<article className="status-card section-stack">
|
||||
<div className="placement-stage-header">
|
||||
<span className="status-label">Scene-layout</span>
|
||||
{selectedPatch ? (
|
||||
<span className={`pill ${saveState === "saving" ? "warning" : "info"}`}>
|
||||
{saveState === "saving" ? "Gemmer..." : saveState === "saved" ? "Gemt" : "Live preview"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="placement-stage">
|
||||
<div className="placement-stage-grid" />
|
||||
{patches.map((patch) => {
|
||||
const position = patch.id === selectedPatch?.id ? draftPosition : patch.position;
|
||||
return (
|
||||
<button
|
||||
key={patch.id}
|
||||
type="button"
|
||||
className={`placement-marker ${patch.id === selectedPatch?.id ? "active" : ""}`}
|
||||
style={{
|
||||
left: `${position.x}%`,
|
||||
top: `${position.y}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${position.rotation}deg)`
|
||||
}}
|
||||
onClick={() => setSelectedPatchId(patch.id)}
|
||||
>
|
||||
<strong>{patch.name}</strong>
|
||||
<span>U{patch.universe} · {patch.start_address}-{patch.end_address}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="status-card section-stack placement-controls">
|
||||
<h3>{selectedPatch?.name ?? "Vælg fixture"}</h3>
|
||||
{selectedPatch ? (
|
||||
<>
|
||||
<div className="muted">
|
||||
{selectedPatch.manufacturer} / {selectedPatch.model} · grupper:{" "}
|
||||
{selectedPatch.group_names.length > 0 ? selectedPatch.group_names.join(", ") : "ingen"}
|
||||
</div>
|
||||
<div className="placement-slider-list">
|
||||
<label className="placement-slider-row">
|
||||
<span className="field-label">X-position</span>
|
||||
<div className="placement-slider-track">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={draftPosition.x}
|
||||
onChange={(event) => updateDraft("x", Number(event.target.value))}
|
||||
/>
|
||||
<span className="channel-value-chip">{draftPosition.x}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="placement-slider-row">
|
||||
<span className="field-label">Y-position</span>
|
||||
<div className="placement-slider-track">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={draftPosition.y}
|
||||
onChange={(event) => updateDraft("y", Number(event.target.value))}
|
||||
/>
|
||||
<span className="channel-value-chip">{draftPosition.y}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="placement-slider-row">
|
||||
<span className="field-label">Højde</span>
|
||||
<div className="placement-slider-track">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={draftPosition.z}
|
||||
onChange={(event) => updateDraft("z", Number(event.target.value))}
|
||||
/>
|
||||
<span className="channel-value-chip">{draftPosition.z}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="placement-slider-row">
|
||||
<span className="field-label">Rotation</span>
|
||||
<div className="placement-slider-track">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={draftPosition.rotation}
|
||||
onChange={(event) => updateDraft("rotation", Number(event.target.value))}
|
||||
/>
|
||||
<span className="channel-value-chip">{draftPosition.rotation}°</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="event-item placement-readout">
|
||||
<strong>Aktuel placering</strong>
|
||||
<div className="muted">
|
||||
X {draftPosition.x} · Y {draftPosition.y} · Højde {draftPosition.z} · Rotation {draftPosition.rotation}°
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { PatchItem, SceneItem } from "../lib/api";
|
||||
|
||||
type SceneTargetDraftValue = Record<string, string>;
|
||||
|
||||
type ScenesPageProps = {
|
||||
scenes: SceneItem[];
|
||||
patches: PatchItem[];
|
||||
onCreate: (payload: Record<string, unknown>) => Promise<void>;
|
||||
onActivate: (slug: string) => Promise<void>;
|
||||
onRelease: (slug: string) => Promise<void>;
|
||||
onDelete: (sceneId: number) => Promise<void>;
|
||||
};
|
||||
|
||||
function buildSlug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function collectAttributes(patch: PatchItem | undefined): string[] {
|
||||
return (patch?.channels ?? []).map((channel) => channel.display_name);
|
||||
}
|
||||
|
||||
export function ScenesPage({ scenes, patches, onCreate, onActivate, onRelease, onDelete }: ScenesPageProps) {
|
||||
const [name, setName] = useState("Stream base");
|
||||
const [slug, setSlug] = useState("stream-base");
|
||||
const [priority, setPriority] = useState(10);
|
||||
const [targetType, setTargetType] = useState<"fixture" | "group">("group");
|
||||
const [patchId, setPatchId] = useState<number>(patches[0]?.id ?? 0);
|
||||
const [groupName, setGroupName] = useState<string>("");
|
||||
const [targetValues, setTargetValues] = useState<SceneTargetDraftValue>({});
|
||||
const [draftTargets, setDraftTargets] = useState<SceneItem["targets"]>([]);
|
||||
|
||||
const availableGroups = useMemo(
|
||||
() => Array.from(new Set(patches.flatMap((patch) => patch.group_names))).sort((left, right) => left.localeCompare(right)),
|
||||
[patches]
|
||||
);
|
||||
const selectedPatch = useMemo(
|
||||
() => patches.find((patch) => patch.id === patchId) ?? patches[0],
|
||||
[patchId, patches]
|
||||
);
|
||||
const selectedGroupPatch = useMemo(
|
||||
() => patches.find((patch) => patch.group_names.includes(groupName)) ?? patches[0],
|
||||
[groupName, patches]
|
||||
);
|
||||
const channelAttributes = useMemo(
|
||||
() => collectAttributes(targetType === "fixture" ? selectedPatch : selectedGroupPatch),
|
||||
[selectedGroupPatch, selectedPatch, targetType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!patches.some((patch) => patch.id === patchId) && patches[0]) {
|
||||
setPatchId(patches[0].id);
|
||||
}
|
||||
if (!groupName && availableGroups[0]) {
|
||||
setGroupName(availableGroups[0]);
|
||||
}
|
||||
}, [availableGroups, groupName, patchId, patches]);
|
||||
|
||||
function resetTargetValues() {
|
||||
setTargetValues({});
|
||||
}
|
||||
|
||||
function addTarget() {
|
||||
const values = Object.entries(targetValues)
|
||||
.filter(([, value]) => value.trim() !== "")
|
||||
.map(([attribute, value]) => ({
|
||||
attribute,
|
||||
value: Number(value),
|
||||
}));
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (targetType === "fixture" && selectedPatch) {
|
||||
setDraftTargets((current) => [
|
||||
...current,
|
||||
{ target_type: "fixture", patch_id: selectedPatch.id, values }
|
||||
]);
|
||||
}
|
||||
if (targetType === "group" && groupName) {
|
||||
setDraftTargets((current) => [
|
||||
...current,
|
||||
{ target_type: "group", group_name: groupName, values }
|
||||
]);
|
||||
}
|
||||
resetTargetValues();
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (draftTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
await onCreate({
|
||||
name,
|
||||
slug,
|
||||
color: "#00e5ff",
|
||||
priority,
|
||||
fade_in_ms: 500,
|
||||
fade_out_ms: 500,
|
||||
hold_ms: 0,
|
||||
master_limit: 255,
|
||||
values: [],
|
||||
targets: draftTargets,
|
||||
tags: [],
|
||||
});
|
||||
setDraftTargets([]);
|
||||
setName("Ny scene");
|
||||
setSlug("ny-scene");
|
||||
setPriority(10);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>Scenes bygget på fixtures og grupper</h2>
|
||||
<p className="muted">
|
||||
Opret scener mod patched fixtures eller hele grupper. Samme gruppe kan bruges som synk/link på tværs af flere lamper.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="workspace-grid">
|
||||
<form className="status-card section-stack" onSubmit={handleSubmit}>
|
||||
<h3>Ny scene</h3>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">Navn</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
const nextName = event.target.value;
|
||||
setName(nextName);
|
||||
setSlug((current) => (current === buildSlug(name) ? buildSlug(nextName) : current));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Slug</span>
|
||||
<input value={slug} onChange={(event) => setSlug(buildSlug(event.target.value))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Prioritet</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Target-type</span>
|
||||
<select value={targetType} onChange={(event) => setTargetType(event.target.value as "fixture" | "group")}>
|
||||
<option value="group">Gruppe</option>
|
||||
<option value="fixture">Fixture</option>
|
||||
</select>
|
||||
</label>
|
||||
{targetType === "fixture" ? (
|
||||
<label className="full-span">
|
||||
<span className="field-label">Fixture</span>
|
||||
<select value={patchId} onChange={(event) => setPatchId(Number(event.target.value))}>
|
||||
{patches.map((patch) => (
|
||||
<option key={patch.id} value={patch.id}>
|
||||
{patch.name} · {patch.manufacturer} / {patch.model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<label className="full-span">
|
||||
<span className="field-label">Gruppe</span>
|
||||
<select value={groupName} onChange={(event) => setGroupName(event.target.value)}>
|
||||
{availableGroups.map((group) => (
|
||||
<option key={group} value={group}>
|
||||
{group}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<article className="event-item section-stack">
|
||||
<strong>Attributter</strong>
|
||||
<div className="target-attribute-list">
|
||||
{channelAttributes.map((attribute) => (
|
||||
<label key={attribute} className="attribute-row-editor">
|
||||
<span className="attribute-row-name">{attribute}</span>
|
||||
<div className="attribute-row-controls">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
value={Number(targetValues[attribute] ?? 0)}
|
||||
onChange={(event) =>
|
||||
setTargetValues((current) => ({
|
||||
...current,
|
||||
[attribute]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={255}
|
||||
className="attribute-row-number"
|
||||
value={targetValues[attribute] ?? ""}
|
||||
onChange={(event) =>
|
||||
setTargetValues((current) => ({
|
||||
...current,
|
||||
[attribute]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="-"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="attribute-row-clear"
|
||||
onClick={() =>
|
||||
setTargetValues((current) => ({
|
||||
...current,
|
||||
[attribute]: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Nul
|
||||
</button>
|
||||
</div>
|
||||
<span className="muted attribute-row-help">Tomt felt ignoreres i scenen</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={addTarget} disabled={channelAttributes.length === 0}>
|
||||
Tilføj target til scene
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="button-row">
|
||||
<button type="submit" disabled={draftTargets.length === 0}>
|
||||
Gem scene
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<article className="status-card section-stack">
|
||||
<h3>Targets i scenen</h3>
|
||||
{draftTargets.length === 0 ? (
|
||||
<div className="muted">Tilføj mindst ét fixture- eller gruppetarget for at gemme scenen.</div>
|
||||
) : (
|
||||
<div className="event-list">
|
||||
{draftTargets.map((target, index) => (
|
||||
<article key={`${target.target_type}-${index}`} className="event-item">
|
||||
<strong>
|
||||
{target.target_type === "fixture"
|
||||
? patches.find((patch) => patch.id === target.patch_id)?.name ?? "Ukendt fixture"
|
||||
: `Gruppe ${target.group_name ?? "ukendt"}`}
|
||||
</strong>
|
||||
<div className="muted">
|
||||
{target.values.map((entry) => `${entry.attribute}=${entry.value}`).join(" · ")}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftTargets((current) => current.filter((_, currentIndex) => currentIndex !== index))}
|
||||
>
|
||||
Fjern target
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="card-grid">
|
||||
{scenes.map((scene) => (
|
||||
<article key={scene.id} className="status-card section-stack">
|
||||
<div className="scene-card-header">
|
||||
<div>
|
||||
<span className="status-label">Prioritet {scene.priority}</span>
|
||||
<h3>{scene.name}</h3>
|
||||
</div>
|
||||
<span className={`pill ${scene.is_active ? "success" : "info"}`}>
|
||||
{scene.is_active ? "Aktiv" : "Klar"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="event-list">
|
||||
{scene.targets.map((target, index) => (
|
||||
<div key={`${scene.id}-${target.target_type}-${index}`} className="event-item">
|
||||
<strong>
|
||||
{target.target_type === "fixture"
|
||||
? patches.find((patch) => patch.id === target.patch_id)?.name ?? "Fixture"
|
||||
: `Gruppe ${target.group_name ?? "ukendt"}`}
|
||||
</strong>
|
||||
<div className="muted">
|
||||
{target.values.map((entry) => `${entry.attribute}=${entry.value}`).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => onActivate(scene.slug)}>Aktivér scene</button>
|
||||
<button type="button" onClick={() => onRelease(scene.slug)}>Frigiv scene</button>
|
||||
<button type="button" onClick={() => onDelete(scene.id)}>Slet scene</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
BpmDevice,
|
||||
BpmStatus,
|
||||
DmxDevice,
|
||||
DmxOutputConfig,
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantEntity,
|
||||
HomeAssistantMapping
|
||||
} from "../lib/api";
|
||||
|
||||
type SettingsPageProps = {
|
||||
bpmStatus: BpmStatus | null;
|
||||
bpmDevices: BpmDevice[];
|
||||
dmxConfig: DmxOutputConfig | null;
|
||||
dmxDevices: DmxDevice[];
|
||||
haConfig: HomeAssistantConfig | null;
|
||||
haEntities: HomeAssistantEntity[];
|
||||
haMappings: HomeAssistantMapping[];
|
||||
onSaveDmxConfig: (payload: {
|
||||
backend: "simulator" | "ola" | "artnet";
|
||||
universe: number;
|
||||
output_port: string;
|
||||
target_host: string;
|
||||
}) => Promise<string>;
|
||||
onDiscoverArtNet: () => Promise<string>;
|
||||
onSaveHomeAssistantConfig: (payload: {
|
||||
enabled: boolean;
|
||||
base_url: string;
|
||||
token: string;
|
||||
default_universe: number;
|
||||
}) => Promise<string>;
|
||||
onTestHomeAssistantConnection: () => Promise<string>;
|
||||
onRefreshHomeAssistantEntities: () => Promise<string>;
|
||||
onSaveHomeAssistantMapping: (
|
||||
payload: {
|
||||
name: string;
|
||||
universe: number;
|
||||
start_address: number;
|
||||
fixture_type: "dimmer" | "rgb" | "rgbw" | "cct" | "switch" | "scene" | "automation";
|
||||
entity_id: string;
|
||||
rate_limit_hz: number;
|
||||
deadband: number;
|
||||
fade_ms: number;
|
||||
invert_channel: boolean;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
enabled: boolean;
|
||||
master_dimmer: boolean;
|
||||
},
|
||||
mappingId: number | null
|
||||
) => Promise<string>;
|
||||
onDeleteHomeAssistantMapping: (mappingId: number) => Promise<void>;
|
||||
onTestHomeAssistantMapping: (mappingId: number) => Promise<string>;
|
||||
onSaveBpmDevice: (device: string) => Promise<string>;
|
||||
onRefreshAudioDevices: () => Promise<void>;
|
||||
onRestartService: () => Promise<string>;
|
||||
onRebootHost: () => Promise<string>;
|
||||
};
|
||||
|
||||
type FixtureType = "dimmer" | "rgb" | "rgbw" | "cct" | "switch" | "scene" | "automation";
|
||||
|
||||
function defaultChannelSpan(fixtureType: FixtureType, masterDimmer: boolean): number {
|
||||
if (fixtureType === "rgb") {
|
||||
return masterDimmer ? 4 : 3;
|
||||
}
|
||||
if (fixtureType === "rgbw") {
|
||||
return masterDimmer ? 5 : 4;
|
||||
}
|
||||
if (fixtureType === "cct") {
|
||||
return masterDimmer ? 3 : 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
bpmStatus,
|
||||
bpmDevices,
|
||||
dmxConfig,
|
||||
dmxDevices,
|
||||
haConfig,
|
||||
haEntities,
|
||||
haMappings,
|
||||
onSaveDmxConfig,
|
||||
onDiscoverArtNet,
|
||||
onSaveHomeAssistantConfig,
|
||||
onTestHomeAssistantConnection,
|
||||
onRefreshHomeAssistantEntities,
|
||||
onSaveHomeAssistantMapping,
|
||||
onDeleteHomeAssistantMapping,
|
||||
onTestHomeAssistantMapping,
|
||||
onSaveBpmDevice,
|
||||
onRefreshAudioDevices,
|
||||
onRestartService,
|
||||
onRebootHost
|
||||
}: SettingsPageProps) {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [audioMessage, setAudioMessage] = useState<string | null>(null);
|
||||
const [dmxMessage, setDmxMessage] = useState<string | null>(null);
|
||||
const [haMessage, setHaMessage] = useState<string | null>(null);
|
||||
|
||||
const [selectedDevice, setSelectedDevice] = useState<string>(bpmStatus?.selected_device ?? "alsa:auto");
|
||||
const [selectedBackend, setSelectedBackend] = useState<"simulator" | "ola" | "artnet">(
|
||||
dmxConfig?.backend ?? "simulator"
|
||||
);
|
||||
const [universe, setUniverse] = useState<number>(dmxConfig?.universe ?? 1);
|
||||
const [outputPort, setOutputPort] = useState<string>(dmxConfig?.output_port ?? "");
|
||||
const [targetHost, setTargetHost] = useState<string>(dmxConfig?.target_host ?? "");
|
||||
const [dmxConfigDirty, setDmxConfigDirty] = useState<boolean>(false);
|
||||
|
||||
const [haEnabled, setHaEnabled] = useState<boolean>(haConfig?.enabled ?? false);
|
||||
const [haBaseUrl, setHaBaseUrl] = useState<string>(haConfig?.base_url ?? "");
|
||||
const [haToken, setHaToken] = useState<string>("");
|
||||
const [haUniverse, setHaUniverse] = useState<number>(haConfig?.default_universe ?? 10);
|
||||
const [haConfigDirty, setHaConfigDirty] = useState<boolean>(false);
|
||||
const [bpmConfigDirty, setBpmConfigDirty] = useState<boolean>(false);
|
||||
|
||||
const [editingMappingId, setEditingMappingId] = useState<number | null>(null);
|
||||
const [mappingName, setMappingName] = useState<string>("");
|
||||
const [mappingUniverse, setMappingUniverse] = useState<number>(haConfig?.default_universe ?? 10);
|
||||
const [mappingStartAddress, setMappingStartAddress] = useState<number>(1);
|
||||
const [mappingFixtureType, setMappingFixtureType] = useState<FixtureType>("rgb");
|
||||
const [mappingEntityId, setMappingEntityId] = useState<string>("");
|
||||
const [mappingRateLimit, setMappingRateLimit] = useState<number>(5);
|
||||
const [mappingDeadband, setMappingDeadband] = useState<number>(2);
|
||||
const [mappingFadeMs, setMappingFadeMs] = useState<number>(150);
|
||||
const [mappingInvert, setMappingInvert] = useState<boolean>(false);
|
||||
const [mappingMinValue, setMappingMinValue] = useState<number>(0);
|
||||
const [mappingMaxValue, setMappingMaxValue] = useState<number>(255);
|
||||
const [mappingEnabled, setMappingEnabled] = useState<boolean>(true);
|
||||
const [mappingMasterDimmer, setMappingMasterDimmer] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (bpmConfigDirty) {
|
||||
return;
|
||||
}
|
||||
setSelectedDevice(bpmStatus?.selected_device ?? "alsa:auto");
|
||||
}, [bpmConfigDirty, bpmStatus?.selected_device]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dmxConfigDirty) {
|
||||
return;
|
||||
}
|
||||
setSelectedBackend(dmxConfig?.backend ?? "simulator");
|
||||
setUniverse(dmxConfig?.universe ?? 1);
|
||||
setOutputPort(dmxConfig?.output_port ?? "");
|
||||
setTargetHost(dmxConfig?.target_host ?? "");
|
||||
}, [dmxConfig, dmxConfigDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (haConfigDirty) {
|
||||
return;
|
||||
}
|
||||
setHaEnabled(haConfig?.enabled ?? false);
|
||||
setHaBaseUrl(haConfig?.base_url ?? "");
|
||||
setHaUniverse(haConfig?.default_universe ?? 10);
|
||||
if (editingMappingId === null) {
|
||||
setMappingUniverse(haConfig?.default_universe ?? 10);
|
||||
}
|
||||
}, [editingMappingId, haConfig, haConfigDirty]);
|
||||
|
||||
const activeDevice = dmxDevices[0] ?? null;
|
||||
|
||||
function resetMappingForm() {
|
||||
setEditingMappingId(null);
|
||||
setMappingName("");
|
||||
setMappingUniverse(haUniverse);
|
||||
setMappingStartAddress(1);
|
||||
setMappingFixtureType("rgb");
|
||||
setMappingEntityId("");
|
||||
setMappingRateLimit(5);
|
||||
setMappingDeadband(2);
|
||||
setMappingFadeMs(150);
|
||||
setMappingInvert(false);
|
||||
setMappingMinValue(0);
|
||||
setMappingMaxValue(255);
|
||||
setMappingEnabled(true);
|
||||
setMappingMasterDimmer(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>Settings</h2>
|
||||
<p className="muted">
|
||||
System, DMX, Home Assistant, BPM og servicekontrol samles her som formularstyrede
|
||||
driftsindstillinger.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="event-item">
|
||||
<strong>Sikkerhedsretning</strong>
|
||||
<div className="muted">
|
||||
Sessions, tokens og strobe/master-grænser skal bevares som administratorspecifikke flows.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="event-item section-stack">
|
||||
<strong>DMX output</strong>
|
||||
<div className="muted">
|
||||
OLA/USB-DMX fortsætter som fysisk output. Art-Net kan vælges separat til netværksnoder.
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">Backend</span>
|
||||
<select
|
||||
value={selectedBackend}
|
||||
onChange={(event) => {
|
||||
setDmxConfigDirty(true);
|
||||
setSelectedBackend(event.target.value as "simulator" | "ola" | "artnet");
|
||||
}}
|
||||
>
|
||||
<option value="simulator">Simulator</option>
|
||||
<option value="ola">OLA / USB-DMX</option>
|
||||
<option value="artnet">Art-Net</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Universe</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={63999}
|
||||
value={universe}
|
||||
onChange={(event) => {
|
||||
setDmxConfigDirty(true);
|
||||
setUniverse(Math.max(1, Number(event.target.value || 1)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{selectedBackend === "ola" ? (
|
||||
<label>
|
||||
<span className="field-label">OLA output-port</span>
|
||||
<input
|
||||
value={outputPort}
|
||||
onChange={(event) => {
|
||||
setDmxConfigDirty(true);
|
||||
setOutputPort(event.target.value);
|
||||
}}
|
||||
placeholder="fx Anyma USB Device"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{selectedBackend === "artnet" ? (
|
||||
<label>
|
||||
<span className="field-label">Art-Net target host</span>
|
||||
<input
|
||||
value={targetHost}
|
||||
onChange={(event) => {
|
||||
setDmxConfigDirty(true);
|
||||
setTargetHost(event.target.value);
|
||||
}}
|
||||
placeholder="fx 192.168.2.60"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Aktiv backend: {activeDevice?.backend ?? "ukendt"} · Forbundet: {activeDevice?.connected ? "ja" : "nej"} ·
|
||||
Output: {activeDevice?.output_port ?? "ikke valgt"} · Universe:{" "}
|
||||
{activeDevice?.universe ?? dmxConfig?.universe ?? 1}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setDmxMessage(
|
||||
await onSaveDmxConfig({
|
||||
backend: selectedBackend,
|
||||
universe,
|
||||
output_port: outputPort,
|
||||
target_host: targetHost
|
||||
})
|
||||
);
|
||||
setDmxConfigDirty(false);
|
||||
}}
|
||||
>
|
||||
Gem DMX-output
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setDmxMessage(await onDiscoverArtNet());
|
||||
}}
|
||||
>
|
||||
Scan efter Art-Net
|
||||
</button>
|
||||
</div>
|
||||
{selectedBackend === "artnet" ? (
|
||||
<div className="event-list">
|
||||
{(dmxConfig?.artnet_nodes ?? []).length > 0 ? (
|
||||
(dmxConfig?.artnet_nodes ?? []).map((node) => (
|
||||
<button
|
||||
key={node.ip}
|
||||
type="button"
|
||||
className="event-item"
|
||||
onClick={() => {
|
||||
setDmxConfigDirty(true);
|
||||
setTargetHost(node.ip);
|
||||
setOutputPort(node.label);
|
||||
setDmxMessage(`Valgt Art-Net node ${node.label}.`);
|
||||
}}
|
||||
>
|
||||
<strong>{node.label}</strong>
|
||||
<div className="muted">
|
||||
Net {node.net} · Sub {node.sub_switch} · Porte {node.port_count}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="muted">Ingen Art-Net svar endnu. Du kan stadig skrive IP manuelt.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{dmxMessage ? <div className="muted">{dmxMessage}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="event-item section-stack">
|
||||
<strong>Home Assistant-bro</strong>
|
||||
<div className="muted">
|
||||
Brug et separat internt universe, fx 10, til Home Assistant-entiteter. Det holder USB-DMX på
|
||||
OLA adskilt fra HA-lys og automationer.
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">Base URL</span>
|
||||
<input
|
||||
value={haBaseUrl}
|
||||
onChange={(event) => {
|
||||
setHaConfigDirty(true);
|
||||
setHaBaseUrl(event.target.value);
|
||||
}}
|
||||
placeholder="fx http://homeassistant.local:8123"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Long-lived token</span>
|
||||
<input
|
||||
type="password"
|
||||
value={haToken}
|
||||
onChange={(event) => {
|
||||
setHaConfigDirty(true);
|
||||
setHaToken(event.target.value);
|
||||
}}
|
||||
placeholder={haConfig?.has_token ? `${haConfig.token_mask} gemt. Skriv kun ved udskiftning.` : "Indsæt token"}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Standard HA-universe</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={63999}
|
||||
value={haUniverse}
|
||||
onChange={(event) => {
|
||||
setHaConfigDirty(true);
|
||||
setHaUniverse(Math.max(1, Number(event.target.value || 10)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Bro aktiv</span>
|
||||
<select
|
||||
value={haEnabled ? "yes" : "no"}
|
||||
onChange={(event) => {
|
||||
setHaConfigDirty(true);
|
||||
setHaEnabled(event.target.value === "yes");
|
||||
}}
|
||||
>
|
||||
<option value="yes">Ja</option>
|
||||
<option value="no">Nej</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="muted">
|
||||
Token gemt: {haConfig?.has_token ? "ja" : "nej"} · Mappings: {haConfig?.mapping_count ?? haMappings.length}
|
||||
{" · "}Sendte events: {haConfig?.dispatch_count ?? 0} · Fejl: {haConfig?.error_count ?? 0}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Forbindelse: {haConfig?.reachable ? "kontakt OK" : "ingen kontakt"} · Auth:{" "}
|
||||
{haConfig?.auth_ok ? "OK" : "ikke verificeret"} · Version: {haConfig?.ha_version ?? "ukendt"} ·
|
||||
Seneste succes:{" "}
|
||||
{haConfig?.last_connection_success_at
|
||||
? new Date(haConfig.last_connection_success_at).toLocaleString("da-DK")
|
||||
: "ingen"}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setHaMessage(
|
||||
await onSaveHomeAssistantConfig({
|
||||
enabled: haEnabled,
|
||||
base_url: haBaseUrl,
|
||||
token: haToken,
|
||||
default_universe: haUniverse
|
||||
})
|
||||
);
|
||||
setHaConfigDirty(false);
|
||||
setHaToken("");
|
||||
}}
|
||||
>
|
||||
Gem HA-config
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setHaMessage(await onTestHomeAssistantConnection());
|
||||
}}
|
||||
>
|
||||
Test forbindelse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setHaMessage(await onRefreshHomeAssistantEntities());
|
||||
}}
|
||||
>
|
||||
Hent HA-enheder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="field-label">Mapping-navn</span>
|
||||
<input value={mappingName} onChange={(event) => setMappingName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Universe</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={63999}
|
||||
value={mappingUniverse}
|
||||
onChange={(event) => setMappingUniverse(Math.max(1, Number(event.target.value || haUniverse)))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Startadresse</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={512}
|
||||
value={mappingStartAddress}
|
||||
onChange={(event) => setMappingStartAddress(Math.max(1, Number(event.target.value || 1)))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Fixturetype</span>
|
||||
<select value={mappingFixtureType} onChange={(event) => setMappingFixtureType(event.target.value as FixtureType)}>
|
||||
<option value="dimmer">Dimmer</option>
|
||||
<option value="rgb">RGB</option>
|
||||
<option value="rgbw">RGBW</option>
|
||||
<option value="cct">CCT</option>
|
||||
<option value="switch">Switch</option>
|
||||
<option value="scene">Scene</option>
|
||||
<option value="automation">Automation</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<span className="field-label">Home Assistant-entity</span>
|
||||
<input
|
||||
list="ha-entity-list"
|
||||
value={mappingEntityId}
|
||||
onChange={(event) => setMappingEntityId(event.target.value)}
|
||||
placeholder="fx light.bar_rgb"
|
||||
/>
|
||||
<datalist id="ha-entity-list">
|
||||
{haEntities.map((entity) => (
|
||||
<option key={entity.entity_id} value={entity.entity_id}>
|
||||
{entity.friendly_name || entity.entity_id}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Rate limit / sek</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={30}
|
||||
step={0.1}
|
||||
value={mappingRateLimit}
|
||||
onChange={(event) => setMappingRateLimit(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Deadband</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={255}
|
||||
value={mappingDeadband}
|
||||
onChange={(event) => setMappingDeadband(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Fade / smoothing ms</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10000}
|
||||
value={mappingFadeMs}
|
||||
onChange={(event) => setMappingFadeMs(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Master dimmer</span>
|
||||
<select
|
||||
value={mappingMasterDimmer ? "yes" : "no"}
|
||||
onChange={(event) => setMappingMasterDimmer(event.target.value === "yes")}
|
||||
>
|
||||
<option value="yes">Ja</option>
|
||||
<option value="no">Nej</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Invertér kanal</span>
|
||||
<select value={mappingInvert ? "yes" : "no"} onChange={(event) => setMappingInvert(event.target.value === "yes")}>
|
||||
<option value="no">Nej</option>
|
||||
<option value="yes">Ja</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Minimumsværdi</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={255}
|
||||
value={mappingMinValue}
|
||||
onChange={(event) => setMappingMinValue(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Maksimumsværdi</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={255}
|
||||
value={mappingMaxValue}
|
||||
onChange={(event) => setMappingMaxValue(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Aktiv</span>
|
||||
<select value={mappingEnabled ? "yes" : "no"} onChange={(event) => setMappingEnabled(event.target.value === "yes")}>
|
||||
<option value="yes">Ja</option>
|
||||
<option value="no">Nej</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="muted">
|
||||
Kanalspan: {defaultChannelSpan(mappingFixtureType, mappingMasterDimmer)} · Mappingen kan holdes på et
|
||||
separat HA-universe, fx {haUniverse}.
|
||||
</div>
|
||||
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setHaMessage(
|
||||
await onSaveHomeAssistantMapping(
|
||||
{
|
||||
name: mappingName,
|
||||
universe: mappingUniverse,
|
||||
start_address: mappingStartAddress,
|
||||
fixture_type: mappingFixtureType,
|
||||
entity_id: mappingEntityId,
|
||||
rate_limit_hz: mappingRateLimit,
|
||||
deadband: mappingDeadband,
|
||||
fade_ms: mappingFadeMs,
|
||||
invert_channel: mappingInvert,
|
||||
min_value: mappingMinValue,
|
||||
max_value: mappingMaxValue,
|
||||
enabled: mappingEnabled,
|
||||
master_dimmer: mappingMasterDimmer
|
||||
},
|
||||
editingMappingId
|
||||
)
|
||||
);
|
||||
resetMappingForm();
|
||||
}}
|
||||
>
|
||||
{editingMappingId === null ? "Gem HA-mapping" : "Opdatér HA-mapping"}
|
||||
</button>
|
||||
{editingMappingId !== null ? (
|
||||
<button type="button" onClick={resetMappingForm}>
|
||||
Annullér redigering
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="event-list">
|
||||
{haMappings.map((mapping) => (
|
||||
<div key={mapping.id} className="event-item">
|
||||
<strong>{mapping.name}</strong>
|
||||
<div className="muted">
|
||||
U{mapping.universe} · {mapping.start_address}-{mapping.start_address + mapping.channel_span - 1} ·{" "}
|
||||
{mapping.fixture_type} · {mapping.entity_id}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Rate {mapping.rate_limit_hz}/s · deadband {mapping.deadband} · fade {mapping.fade_ms} ms ·{" "}
|
||||
{mapping.enabled ? "aktiv" : "deaktiv"}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Status: {mapping.in_flight ? "sender" : mapping.status} · Seneste DMX:{" "}
|
||||
{mapping.last_dmx_values.length > 0 ? mapping.last_dmx_values.join(", ") : "ingen"} ·
|
||||
Senest sendt: {mapping.last_sent_summary ?? "ingen"}
|
||||
</div>
|
||||
<div className="muted">
|
||||
Seneste succes: {mapping.last_success_at ? new Date(mapping.last_success_at).toLocaleString("da-DK") : "ingen"} ·
|
||||
Seneste fejl: {mapping.last_error ?? "ingen"}
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingMappingId(mapping.id);
|
||||
setMappingName(mapping.name);
|
||||
setMappingUniverse(mapping.universe);
|
||||
setMappingStartAddress(mapping.start_address);
|
||||
setMappingFixtureType(mapping.fixture_type);
|
||||
setMappingEntityId(mapping.entity_id);
|
||||
setMappingRateLimit(mapping.rate_limit_hz);
|
||||
setMappingDeadband(mapping.deadband);
|
||||
setMappingFadeMs(mapping.fade_ms);
|
||||
setMappingInvert(mapping.invert_channel);
|
||||
setMappingMinValue(mapping.min_value);
|
||||
setMappingMaxValue(mapping.max_value);
|
||||
setMappingEnabled(mapping.enabled);
|
||||
setMappingMasterDimmer(mapping.master_dimmer);
|
||||
}}
|
||||
>
|
||||
Redigér
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setHaMessage(await onTestHomeAssistantMapping(mapping.id));
|
||||
}}
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await onDeleteHomeAssistantMapping(mapping.id);
|
||||
setHaMessage("HA-mapping slettet.");
|
||||
if (editingMappingId === mapping.id) {
|
||||
resetMappingForm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Slet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{haMappings.length === 0 ? (
|
||||
<div className="muted">
|
||||
Ingen HA-mappings endnu. Brug fx universe 10 adresse 1-4 til en RGB-lampe med master dimmer.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{haConfig?.last_error ? <div className="muted">{haConfig.last_error}</div> : null}
|
||||
{haMessage ? <div className="muted">{haMessage}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="event-item section-stack">
|
||||
<strong>BPM og mikrofon</strong>
|
||||
<div className="muted">
|
||||
Vælg hvilket ALSA-input der skal bruges som standard til BPM og lydanalyse. Auto prøver først
|
||||
`plughw:CARD=SB,DEV=0`, derefter `plughw:1,0` og til sidst `default`.
|
||||
</div>
|
||||
<label>
|
||||
<span className="field-label">ALSA-input</span>
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(event) => {
|
||||
setBpmConfigDirty(true);
|
||||
setSelectedDevice(event.target.value);
|
||||
}}
|
||||
>
|
||||
{bpmDevices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name}
|
||||
{device.recommended ? " · anbefalet" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="muted">
|
||||
Aktivt input: {bpmStatus?.audio_connected ? bpmStatus.current_device.replace(/^alsa:/, "") : "ingen"} ·
|
||||
Niveau: {Math.round((bpmStatus?.input_level ?? 0) * 100)}% · Format: {bpmStatus?.format ?? "S16_LE"} /{" "}
|
||||
{bpmStatus?.sample_rate ?? 44100} Hz / {bpmStatus?.channels ?? 1} kanal
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setAudioMessage(await onSaveBpmDevice(selectedDevice));
|
||||
setBpmConfigDirty(false);
|
||||
}}
|
||||
>
|
||||
Gem audio-input
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await onRefreshAudioDevices();
|
||||
setAudioMessage("Audio-device-listen er opdateret.");
|
||||
}}
|
||||
>
|
||||
Opdater device-liste
|
||||
</button>
|
||||
</div>
|
||||
{bpmStatus?.last_error ? <div className="muted">{bpmStatus.last_error}</div> : <div className="muted">Ingen inputfejl registreret.</div>}
|
||||
{audioMessage ? <div className="muted">{audioMessage}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="event-item">
|
||||
<strong>Systemstyring</strong>
|
||||
<div className="muted">
|
||||
Brug disse handlinger til at genstarte TuxDMX-servicen eller hele Linux-maskinen efter
|
||||
konfigurationsændringer.
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setMessage(await onRestartService());
|
||||
}}
|
||||
>
|
||||
Genstart TuxDMX
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!window.confirm("Vil du genstarte hele computeren nu?")) {
|
||||
return;
|
||||
}
|
||||
setMessage(await onRebootHost());
|
||||
}}
|
||||
>
|
||||
Genstart computer
|
||||
</button>
|
||||
</div>
|
||||
{message ? <div className="muted">{message}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SystemStatus } from "../lib/api";
|
||||
|
||||
type TelemetryPageProps = {
|
||||
status: SystemStatus | null;
|
||||
};
|
||||
|
||||
export function TelemetryPage({ status }: TelemetryPageProps) {
|
||||
return (
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<h2>Telemetry</h2>
|
||||
<p className="muted">
|
||||
Systemtelemetri viser software- og backendniveau. Fysisk DMX-signal ved lampen er ikke
|
||||
verificeret uden ekstern måling eller understøttet RX/RDM.
|
||||
</p>
|
||||
</div>
|
||||
<article className="status-card summary-panel">
|
||||
<div className="compact-stat-bar dense" aria-label="Telemetry status">
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">CPU</span>
|
||||
<strong>{status?.telemetry.cpu_percent ?? 0}%</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">RAM</span>
|
||||
<strong>{status?.telemetry.ram_percent ?? 0}%</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Temperatur</span>
|
||||
<strong>{status?.telemetry.temperature_c ?? "n/a"}</strong>
|
||||
</div>
|
||||
<div className="compact-stat-pill">
|
||||
<span className="status-label">Reconnects</span>
|
||||
<strong>{status?.telemetry.reconnect_count ?? 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div className="event-list">
|
||||
{(status?.telemetry.events ?? []).map((event, index) => (
|
||||
<div key={`${event.created_at}-${index}`} className="event-item">
|
||||
<strong>
|
||||
{event.category} · {event.level}
|
||||
</strong>
|
||||
<div className="muted">{event.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { test, vi } from "vitest";
|
||||
|
||||
import App from "../src/App";
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) => {
|
||||
let payload: Record<string, unknown> = { items: [] };
|
||||
if (url.includes("/system/status")) {
|
||||
payload = {
|
||||
app: "TuxDMX",
|
||||
setup_required: false,
|
||||
engine: {
|
||||
backend: "simulator",
|
||||
connected: true,
|
||||
degraded: false,
|
||||
last_error: null,
|
||||
blackout: false,
|
||||
fps: 30,
|
||||
master: 255,
|
||||
frame: Array.from({ length: 512 }, () => 0),
|
||||
source_map: Array.from({ length: 512 }, () => "idle")
|
||||
},
|
||||
telemetry: {
|
||||
uptime_seconds: 10,
|
||||
cpu_percent: 20,
|
||||
ram_percent: 30,
|
||||
temperature_c: null,
|
||||
queue_depth: 0,
|
||||
reconnect_count: 0,
|
||||
last_error: null,
|
||||
events: []
|
||||
},
|
||||
bpm: {
|
||||
mode: "manual",
|
||||
bpm: 120,
|
||||
confidence: 1,
|
||||
devices: [],
|
||||
device_details: [],
|
||||
current_device: "alsa:auto",
|
||||
selected_device: "alsa:auto",
|
||||
recommended_device: "alsa:auto",
|
||||
audio_connected: false,
|
||||
last_error: null,
|
||||
input_level: 0,
|
||||
peak_level: 0,
|
||||
clipping: false,
|
||||
sample_rate: 44100,
|
||||
channels: 1,
|
||||
format: "S16_LE"
|
||||
}
|
||||
};
|
||||
} else if (url.includes("/dmx/config")) {
|
||||
payload = {
|
||||
backend: "simulator",
|
||||
universe: 1,
|
||||
output_port: "simulator",
|
||||
target_host: "",
|
||||
artnet_nodes: []
|
||||
};
|
||||
} else if (url.includes("/dmx/devices")) {
|
||||
payload = {
|
||||
devices: [
|
||||
{
|
||||
name: "Simulator universe 1",
|
||||
backend: "simulator",
|
||||
connected: true,
|
||||
output_port: "simulator",
|
||||
universe: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
} else if (url.includes("/integrations/home-assistant/config")) {
|
||||
payload = {
|
||||
enabled: false,
|
||||
base_url: "",
|
||||
default_universe: 10,
|
||||
has_token: false,
|
||||
token_mask: "",
|
||||
mapping_count: 0,
|
||||
dispatch_count: 0,
|
||||
error_count: 0,
|
||||
last_error: null,
|
||||
last_successful_call_at: null,
|
||||
last_connection_success_at: null,
|
||||
last_connection_error: null,
|
||||
ha_version: null,
|
||||
auth_ok: false,
|
||||
reachable: false
|
||||
};
|
||||
} else if (url.includes("/integrations/home-assistant/mappings")) {
|
||||
payload = { items: [] };
|
||||
} else if (url.includes("/integrations/midi/bridges")) {
|
||||
payload = { items: [] };
|
||||
} else if (url.includes("/integrations/midi/mappings")) {
|
||||
payload = { items: [] };
|
||||
} else if (url.includes("/integrations/midi/tokens")) {
|
||||
payload = { items: [] };
|
||||
} else if (url.includes("/integrations/midi/learn")) {
|
||||
payload = { active: false, allow_passthrough: false, expires_at: null, captured_event: null };
|
||||
} else if (url.includes("/bpm/devices")) {
|
||||
payload = { devices: [] };
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(payload)
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
class MockWebSocket {
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
close() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
test("viser dashboard shell", async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByText("TuxDMX")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Operations-dashboard")).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { MidiPage } from "../src/pages/MidiPage";
|
||||
|
||||
const noopString = vi.fn(async () => "");
|
||||
const noopVoid = vi.fn(async () => {});
|
||||
const noopCreateToken = vi.fn(async () => ({
|
||||
id: 1,
|
||||
label: "Test",
|
||||
bridge_id: null,
|
||||
scopes: ["midi:events"],
|
||||
created_at: new Date().toISOString(),
|
||||
revoked_at: null,
|
||||
last_used_at: null,
|
||||
token_preview: "********test",
|
||||
token: "secret"
|
||||
}));
|
||||
|
||||
function buildProps(overrides?: Partial<ComponentProps<typeof MidiPage>>): ComponentProps<typeof MidiPage> {
|
||||
return {
|
||||
bridges: [
|
||||
{
|
||||
bridge_id: "bar-laptop",
|
||||
device_name: "USB MIDI Controller",
|
||||
ip_address: "192.168.2.44",
|
||||
protocol_version: 1,
|
||||
online: true,
|
||||
last_heartbeat_at: "2026-07-24T20:30:10+02:00",
|
||||
last_event_at: "2026-07-24T20:30:12+02:00",
|
||||
last_error: null,
|
||||
last_event: { type: "note_on", channel: 0, number: 36, value: 127 },
|
||||
updated_at: "2026-07-24T20:30:12+02:00",
|
||||
created_at: "2026-07-24T20:20:00+02:00"
|
||||
}
|
||||
],
|
||||
mappings: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Scene A",
|
||||
enabled: true,
|
||||
bridge_id: "bar-laptop",
|
||||
device_name: "USB*",
|
||||
message_type: "note_on",
|
||||
channel: 0,
|
||||
number: 36,
|
||||
action: "activate_scene",
|
||||
target_type: "scene",
|
||||
target_id: "stream-base",
|
||||
mode: "trigger",
|
||||
minimum_value: 1,
|
||||
maximum_value: 127,
|
||||
created_at: "2026-07-24T20:20:00+02:00",
|
||||
updated_at: "2026-07-24T20:20:00+02:00",
|
||||
active: true
|
||||
}
|
||||
],
|
||||
tokens: [
|
||||
{
|
||||
id: 1,
|
||||
label: "Bar laptop",
|
||||
bridge_id: "bar-laptop",
|
||||
scopes: ["midi:connect", "midi:events", "midi:heartbeat"],
|
||||
created_at: "2026-07-24T20:10:00+02:00",
|
||||
revoked_at: null,
|
||||
last_used_at: null,
|
||||
token_preview: "********9f2a"
|
||||
}
|
||||
],
|
||||
learnState: {
|
||||
active: false,
|
||||
allow_passthrough: false,
|
||||
expires_at: null,
|
||||
captured_event: {
|
||||
bridge_id: "bar-laptop",
|
||||
device: "USB MIDI Controller",
|
||||
timestamp: "2026-07-24T20:28:00+02:00",
|
||||
message: { type: "control_change", channel: 0, number: 14, value: 64 }
|
||||
}
|
||||
},
|
||||
scenes: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Stream base",
|
||||
slug: "stream-base",
|
||||
color: "#00e5ff",
|
||||
icon: null,
|
||||
priority: 10,
|
||||
fade_in_ms: 500,
|
||||
fade_out_ms: 500,
|
||||
hold_ms: 0,
|
||||
master_limit: 255,
|
||||
values: [],
|
||||
targets: [],
|
||||
tags: [],
|
||||
is_active: false
|
||||
}
|
||||
],
|
||||
effects: [{ name: "Raid flash", slug: "raid-flash", effect_type: "beat-flash" }],
|
||||
onSaveMapping: noopString,
|
||||
onDeleteMapping: noopVoid,
|
||||
onTestMapping: noopString,
|
||||
onCreateToken: noopCreateToken,
|
||||
onRevokeToken: noopVoid,
|
||||
onStartLearn: noopString,
|
||||
onCancelLearn: noopString,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("viser learn-state, bridge og mappingoversigt", () => {
|
||||
render(<MidiPage {...buildProps()} />);
|
||||
expect(screen.getByText("MIDI bridge og mappings")).toBeInTheDocument();
|
||||
expect(screen.getByText("USB MIDI Controller")).toBeInTheDocument();
|
||||
expect(screen.getByText("Scene A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bar laptop")).toBeInTheDocument();
|
||||
expect(screen.getByText(/nummer 14 · værdi 64/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { SettingsPage } from "../src/pages/SettingsPage";
|
||||
|
||||
const noopString = vi.fn(async () => "");
|
||||
const noopVoid = vi.fn(async () => {});
|
||||
|
||||
function buildProps(overrides?: Partial<ComponentProps<typeof SettingsPage>>): ComponentProps<typeof SettingsPage> {
|
||||
return {
|
||||
bpmStatus: {
|
||||
mode: "manual",
|
||||
bpm: 120,
|
||||
confidence: 1,
|
||||
devices: [],
|
||||
current_device: "alsa:plughw:1,0",
|
||||
selected_device: "alsa:auto",
|
||||
recommended_device: "alsa:plughw:1,0",
|
||||
audio_connected: true,
|
||||
last_error: null,
|
||||
input_level: 0.12,
|
||||
peak_level: 0.25,
|
||||
clipping: false,
|
||||
sample_rate: 44100,
|
||||
channels: 1,
|
||||
format: "S16_LE"
|
||||
},
|
||||
bpmDevices: [
|
||||
{ id: "alsa:auto", name: "Auto", backend: "alsa", is_default: true },
|
||||
{ id: "alsa:plughw:1,0", name: "plughw:1,0", backend: "alsa", is_default: false }
|
||||
],
|
||||
dmxConfig: {
|
||||
backend: "artnet",
|
||||
universe: 1,
|
||||
output_port: "WLED Matrix Loft",
|
||||
target_host: "",
|
||||
artnet_nodes: []
|
||||
},
|
||||
dmxDevices: [],
|
||||
haConfig: {
|
||||
enabled: true,
|
||||
base_url: "",
|
||||
default_universe: 10,
|
||||
has_token: true,
|
||||
token_mask: "********",
|
||||
mapping_count: 0,
|
||||
dispatch_count: 0,
|
||||
error_count: 0,
|
||||
last_error: null,
|
||||
last_successful_call_at: null,
|
||||
last_connection_success_at: null,
|
||||
last_connection_error: null,
|
||||
ha_version: null,
|
||||
auth_ok: false,
|
||||
reachable: false
|
||||
},
|
||||
haEntities: [],
|
||||
haMappings: [],
|
||||
onSaveDmxConfig: noopString,
|
||||
onDiscoverArtNet: noopString,
|
||||
onSaveHomeAssistantConfig: noopString,
|
||||
onTestHomeAssistantConnection: noopString,
|
||||
onRefreshHomeAssistantEntities: noopString,
|
||||
onSaveHomeAssistantMapping: noopString,
|
||||
onDeleteHomeAssistantMapping: noopVoid,
|
||||
onTestHomeAssistantMapping: noopString,
|
||||
onSaveBpmDevice: noopString,
|
||||
onRefreshAudioDevices: noopVoid,
|
||||
onRestartService: noopString,
|
||||
onRebootHost: noopString,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("bevarer HA base url mens live refresh opdaterer props", () => {
|
||||
const view = render(<SettingsPage {...buildProps()} />);
|
||||
const input = screen.getByPlaceholderText("fx http://homeassistant.local:8123") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "http://192.168.2.50:8123" } });
|
||||
expect(input.value).toBe("http://192.168.2.50:8123");
|
||||
|
||||
view.rerender(
|
||||
<SettingsPage
|
||||
{...buildProps({
|
||||
haConfig: {
|
||||
...buildProps().haConfig,
|
||||
base_url: ""
|
||||
}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("fx http://homeassistant.local:8123")).toHaveValue(
|
||||
"http://192.168.2.50:8123"
|
||||
);
|
||||
});
|
||||
|
||||
test("bevarer Art-Net target host mens live refresh opdaterer props", () => {
|
||||
const view = render(<SettingsPage {...buildProps()} />);
|
||||
const input = screen.getByPlaceholderText("fx 192.168.2.60") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "192.168.2.77" } });
|
||||
expect(input.value).toBe("192.168.2.77");
|
||||
|
||||
view.rerender(
|
||||
<SettingsPage
|
||||
{...buildProps({
|
||||
dmxConfig: {
|
||||
backend: "artnet",
|
||||
universe: 1,
|
||||
output_port: "WLED Matrix Loft",
|
||||
target_host: "",
|
||||
artnet_nodes: []
|
||||
}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("fx 192.168.2.60")).toHaveValue("192.168.2.77");
|
||||
});
|
||||
|
||||
test("bevarer valgt audio-input mens live refresh opdaterer props", () => {
|
||||
const view = render(<SettingsPage {...buildProps()} />);
|
||||
const select = screen.getByLabelText("ALSA-input") as HTMLSelectElement;
|
||||
|
||||
fireEvent.change(select, { target: { value: "alsa:plughw:1,0" } });
|
||||
expect(select.value).toBe("alsa:plughw:1,0");
|
||||
|
||||
view.rerender(
|
||||
<SettingsPage
|
||||
{...buildProps({
|
||||
bpmStatus: {
|
||||
...buildProps().bpmStatus!,
|
||||
selected_device: "alsa:auto"
|
||||
}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("ALSA-input")).toHaveValue("alsa:plughw:1,0");
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
Reference in New Issue
Block a user