Die gemeinsame Plattform ist jetzt softwareseitig deutlich vollständiger. Der Host-Core hat mit [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) eine echte Runtime-Bibliothek und Persistenz für aktive Szene, Runtime-Presets, Runtime-Gruppen und kreative Varianten bekommen; die Simulation in [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>) liefert jetzt typisierte Command-Ergebnisse, saubere Fehlercodes und persistiert nach data/runtime_state.json. Dazu kommt das generische External-Show-Control-Interface in [external_control.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/external_control.rs>), damit spätere Adapter nur auf definierte Commands und Snapshot-/Preset-/Parameter-Flächen zugreifen.
Die API v1 ist als Produktgrenze geschärft in [dto.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/dto.rs>) und [server.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/server.rs>): getrennte Modelle für `state`, `preview`, `snapshot`, `command response`, `event stream` und stabile Fehlerobjekte mit echten Codes statt generischem Fallback. Dazu kamen `GET /api/v1/state` und `GET /api/v1/preview`, neue persistenzbezogene Commands wie `save_preset`, `save_creative_snapshot`, `recall_creative_snapshot`, `set_transition_style` und `upsert_group`, plus serverseitige Durchreichung der echten Fehlercodes. Die kreative Web-UI in [index.html](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/index.html>), [app.js](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/app.js>) und [styles.css](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/styles.css>) nutzt jetzt genau diese API für Preset-Speichern/Überschreiben, Varianten, Transition-Style, filterbaren Eventfeed und klarere Preview-Darstellung, ohne Parallelarchitektur. Die Doku ist auf den neuen Stand gezogen in [docs/host_api.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>), [README.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/README.md>), [docs/build_and_deploy.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/build_and_deploy.md>) und [docs/architecture.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>). Verifiziert habe ich `cargo check -q` und `cargo test -q`; dabei laufen die erweiterten Contract- und Persistenztests in [contract.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/tests/contract.rs>) sowie neue Core-Tests in [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) und [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>). Nicht separat verifiziert habe ich einen echten Browserlauf der Web-UI; die JS-Datei wurde hier nicht mit `node` geprüft, weil `node` in dieser Umgebung nicht installiert ist.
This commit is contained in:
268
web/v1/app.js
268
web/v1/app.js
@@ -1,9 +1,8 @@
|
||||
(function () {
|
||||
const apiState = {
|
||||
snapshot: null,
|
||||
stateResponse: null,
|
||||
previewResponse: null,
|
||||
catalog: null,
|
||||
presets: [],
|
||||
groups: [],
|
||||
events: [],
|
||||
ws: null,
|
||||
commandTimers: new Map(),
|
||||
@@ -18,15 +17,27 @@
|
||||
patternSelect: document.getElementById("pattern-select"),
|
||||
transitionSlider: document.getElementById("transition-slider"),
|
||||
transitionValue: document.getElementById("transition-value"),
|
||||
transitionStyleSelect: document.getElementById("transition-style-select"),
|
||||
brightnessSlider: document.getElementById("brightness-slider"),
|
||||
brightnessValue: document.getElementById("brightness-value"),
|
||||
blackoutButton: document.getElementById("blackout-button"),
|
||||
presetList: document.getElementById("preset-list"),
|
||||
presetIdInput: document.getElementById("preset-id-input"),
|
||||
presetOverwriteInput: document.getElementById("preset-overwrite-input"),
|
||||
savePresetButton: document.getElementById("save-preset-button"),
|
||||
groupFilterInput: document.getElementById("group-filter-input"),
|
||||
groupList: document.getElementById("group-list"),
|
||||
snapshotIdInput: document.getElementById("snapshot-id-input"),
|
||||
snapshotLabelInput: document.getElementById("snapshot-label-input"),
|
||||
snapshotOverwriteInput: document.getElementById("snapshot-overwrite-input"),
|
||||
saveSnapshotButton: document.getElementById("save-snapshot-button"),
|
||||
snapshotList: document.getElementById("snapshot-list"),
|
||||
sceneParams: document.getElementById("scene-params"),
|
||||
previewGrid: document.getElementById("preview-grid"),
|
||||
summaryCards: document.getElementById("summary-cards"),
|
||||
snapshotJson: document.getElementById("snapshot-json"),
|
||||
eventKindFilter: document.getElementById("event-kind-filter"),
|
||||
eventSearchFilter: document.getElementById("event-search-filter"),
|
||||
eventList: document.getElementById("event-list"),
|
||||
};
|
||||
|
||||
@@ -49,12 +60,19 @@
|
||||
dom.transitionSlider.addEventListener("input", (event) => {
|
||||
const value = Number(event.target.value);
|
||||
dom.transitionValue.textContent = `${value} ms`;
|
||||
debounceCommand("transition", {
|
||||
debounceCommand("transition-duration", {
|
||||
type: "set_transition_duration_ms",
|
||||
payload: { duration_ms: value },
|
||||
});
|
||||
});
|
||||
|
||||
dom.transitionStyleSelect.addEventListener("change", (event) => {
|
||||
sendCommand({
|
||||
type: "set_transition_style",
|
||||
payload: { style: event.target.value },
|
||||
});
|
||||
});
|
||||
|
||||
dom.brightnessSlider.addEventListener("input", (event) => {
|
||||
const value = Number(event.target.value);
|
||||
dom.brightnessValue.textContent = `${Math.round(value * 100)}%`;
|
||||
@@ -65,36 +83,83 @@
|
||||
});
|
||||
|
||||
dom.blackoutButton.addEventListener("click", () => {
|
||||
const enabled = !(apiState.snapshot?.state?.global?.blackout ?? false);
|
||||
const enabled = !(apiState.stateResponse?.state?.global?.blackout ?? false);
|
||||
sendCommand({
|
||||
type: "set_blackout",
|
||||
payload: { enabled },
|
||||
});
|
||||
});
|
||||
|
||||
dom.savePresetButton.addEventListener("click", async () => {
|
||||
const presetId = dom.presetIdInput.value.trim();
|
||||
if (!presetId) {
|
||||
pushEvent({
|
||||
at: new Date().toLocaleTimeString(),
|
||||
kind: "warning",
|
||||
code: "preset_id_required",
|
||||
message: "Preset ID is required before saving.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await sendCommand({
|
||||
type: "save_preset",
|
||||
payload: {
|
||||
preset_id: presetId,
|
||||
overwrite: dom.presetOverwriteInput.checked,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
dom.saveSnapshotButton.addEventListener("click", async () => {
|
||||
const snapshotId = dom.snapshotIdInput.value.trim();
|
||||
if (!snapshotId) {
|
||||
pushEvent({
|
||||
at: new Date().toLocaleTimeString(),
|
||||
kind: "warning",
|
||||
code: "snapshot_id_required",
|
||||
message: "Snapshot ID is required before saving a creative variant.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await sendCommand({
|
||||
type: "save_creative_snapshot",
|
||||
payload: {
|
||||
snapshot_id: snapshotId,
|
||||
label: dom.snapshotLabelInput.value.trim() || null,
|
||||
overwrite: dom.snapshotOverwriteInput.checked,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
dom.groupFilterInput.addEventListener("input", () => renderGroups());
|
||||
dom.eventKindFilter.addEventListener("change", () => renderEvents());
|
||||
dom.eventSearchFilter.addEventListener("input", () => renderEvents());
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
setConnectionState("connecting", "loading");
|
||||
try {
|
||||
const [snapshot, catalog, presets, groups] = await Promise.all([
|
||||
fetchJson("/api/v1/snapshot"),
|
||||
const [stateResponse, previewResponse, catalog] = await Promise.all([
|
||||
fetchJson("/api/v1/state"),
|
||||
fetchJson("/api/v1/preview"),
|
||||
fetchJson("/api/v1/catalog"),
|
||||
fetchJson("/api/v1/presets"),
|
||||
fetchJson("/api/v1/groups"),
|
||||
]);
|
||||
|
||||
apiState.snapshot = snapshot;
|
||||
apiState.stateResponse = stateResponse;
|
||||
apiState.previewResponse = previewResponse;
|
||||
apiState.catalog = catalog;
|
||||
apiState.presets = presets.presets || catalog.presets || [];
|
||||
apiState.groups = groups.groups || catalog.groups || [];
|
||||
|
||||
renderAll();
|
||||
setConnectionState("online", "HTTP snapshot synced");
|
||||
setConnectionState("online", "HTTP sync");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setConnectionState("offline", "snapshot fetch failed");
|
||||
pushEvent({
|
||||
at: new Date().toLocaleTimeString(),
|
||||
kind: "error",
|
||||
code: "http_refresh_failed",
|
||||
message: `HTTP refresh failed: ${error.message}`,
|
||||
});
|
||||
}
|
||||
@@ -110,6 +175,8 @@
|
||||
setConnectionState("online", "stream connected");
|
||||
pushEvent({
|
||||
at: new Date().toLocaleTimeString(),
|
||||
kind: "info",
|
||||
code: "stream_connected",
|
||||
message: "WebSocket stream connected",
|
||||
});
|
||||
});
|
||||
@@ -123,6 +190,8 @@
|
||||
setConnectionState("offline", "stream disconnected");
|
||||
pushEvent({
|
||||
at: new Date().toLocaleTimeString(),
|
||||
kind: "warning",
|
||||
code: "stream_reconnect",
|
||||
message: "WebSocket stream closed, retrying",
|
||||
});
|
||||
window.setTimeout(connectStream, 1500);
|
||||
@@ -139,26 +208,22 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiState.snapshot) {
|
||||
apiState.snapshot = {
|
||||
if (message.type === "snapshot") {
|
||||
apiState.stateResponse = {
|
||||
api_version: envelope.api_version,
|
||||
generated_at_millis: envelope.generated_at_millis,
|
||||
state: null,
|
||||
preview: null,
|
||||
state: message.payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (message.type === "snapshot") {
|
||||
apiState.snapshot.api_version = envelope.api_version;
|
||||
apiState.snapshot.generated_at_millis = envelope.generated_at_millis;
|
||||
apiState.snapshot.state = message.payload;
|
||||
renderState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "preview") {
|
||||
apiState.snapshot.preview = message.payload;
|
||||
apiState.snapshot.generated_at_millis = envelope.generated_at_millis;
|
||||
apiState.previewResponse = {
|
||||
api_version: envelope.api_version,
|
||||
generated_at_millis: envelope.generated_at_millis,
|
||||
preview: message.payload,
|
||||
};
|
||||
renderPreview();
|
||||
renderSnapshotJson();
|
||||
return;
|
||||
@@ -167,6 +232,8 @@
|
||||
if (message.type === "event") {
|
||||
pushEvent({
|
||||
at: `${envelope.generated_at_millis} ms`,
|
||||
kind: message.payload.kind || "info",
|
||||
code: message.payload.code || null,
|
||||
message: message.payload.message,
|
||||
});
|
||||
}
|
||||
@@ -184,15 +251,21 @@
|
||||
});
|
||||
pushEvent({
|
||||
at: new Date().toLocaleTimeString(),
|
||||
kind: "info",
|
||||
code: response.command_type,
|
||||
message: response.summary,
|
||||
});
|
||||
await refreshAll();
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pushEvent({
|
||||
at: new Date().toLocaleTimeString(),
|
||||
kind: "error",
|
||||
code: "command_failed",
|
||||
message: `Command failed: ${error.message}`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,17 +305,16 @@
|
||||
}
|
||||
|
||||
function renderState() {
|
||||
if (!apiState.snapshot?.state) {
|
||||
const state = apiState.stateResponse?.state;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = apiState.snapshot;
|
||||
const state = snapshot.state;
|
||||
const global = state.global;
|
||||
const scene = state.active_scene;
|
||||
|
||||
dom.projectName.textContent = state.system.project_name;
|
||||
dom.topologyLabel.textContent = `${state.system.topology_label} / API ${snapshot.api_version}`;
|
||||
dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`;
|
||||
|
||||
dom.patternSelect.innerHTML = "";
|
||||
(apiState.catalog?.patterns || []).forEach((pattern) => {
|
||||
@@ -255,22 +327,23 @@
|
||||
|
||||
dom.transitionSlider.value = String(global.transition_duration_ms);
|
||||
dom.transitionValue.textContent = `${global.transition_duration_ms} ms`;
|
||||
dom.transitionStyleSelect.value = global.transition_style;
|
||||
dom.brightnessSlider.value = String(global.master_brightness);
|
||||
dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`;
|
||||
dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout";
|
||||
dom.blackoutButton.classList.toggle("is-active", global.blackout);
|
||||
|
||||
renderPresetButtons(scene, global);
|
||||
renderGroupButtons(global);
|
||||
renderPresets(scene);
|
||||
renderGroups(global);
|
||||
renderCreativeSnapshots();
|
||||
renderSceneParameters(scene);
|
||||
renderSummaryCards(state, snapshot.generated_at_millis);
|
||||
renderSummaryCards(state);
|
||||
renderSnapshotJson();
|
||||
dom.previewUpdated.textContent = `${snapshot.generated_at_millis} ms`;
|
||||
}
|
||||
|
||||
function renderPresetButtons(scene) {
|
||||
function renderPresets(scene) {
|
||||
dom.presetList.innerHTML = "";
|
||||
const presets = apiState.presets || [];
|
||||
const presets = apiState.catalog?.presets || [];
|
||||
if (!presets.length) {
|
||||
dom.presetList.innerHTML = '<div class="empty-state">No presets available.</div>';
|
||||
return;
|
||||
@@ -283,7 +356,7 @@
|
||||
button.classList.toggle("active", scene.preset_id === preset.preset_id);
|
||||
button.innerHTML = `
|
||||
<strong>${preset.preset_id}</strong>
|
||||
<div class="pill-subtext">${preset.pattern_id} / ${preset.transition_duration_ms} ms</div>
|
||||
<div class="pill-subtext">${preset.pattern_id} / ${preset.transition_style} / ${preset.source}</div>
|
||||
`;
|
||||
button.addEventListener("click", () =>
|
||||
sendCommand({
|
||||
@@ -295,8 +368,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderGroupButtons(global) {
|
||||
function renderGroups(global) {
|
||||
dom.groupList.innerHTML = "";
|
||||
const filterValue = dom.groupFilterInput.value.trim().toLowerCase();
|
||||
const groups = (apiState.catalog?.groups || []).filter((group) => {
|
||||
if (!filterValue) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
group.group_id.toLowerCase().includes(filterValue) ||
|
||||
(group.tags || []).some((tag) => tag.toLowerCase().includes(filterValue))
|
||||
);
|
||||
});
|
||||
|
||||
const allButton = document.createElement("button");
|
||||
allButton.type = "button";
|
||||
@@ -311,14 +394,22 @@
|
||||
);
|
||||
dom.groupList.appendChild(allButton);
|
||||
|
||||
(apiState.groups || []).forEach((group) => {
|
||||
if (!groups.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty-state";
|
||||
empty.textContent = "No groups match the current filter.";
|
||||
dom.groupList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
groups.forEach((group) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "group-button";
|
||||
button.classList.toggle("active", group.group_id === global.selected_group);
|
||||
button.innerHTML = `
|
||||
<strong>${group.group_id}</strong>
|
||||
<div class="pill-subtext">${group.member_count} members</div>
|
||||
<div class="pill-subtext">${group.member_count} members / ${group.source}</div>
|
||||
`;
|
||||
button.addEventListener("click", () =>
|
||||
sendCommand({
|
||||
@@ -330,6 +421,43 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderCreativeSnapshots() {
|
||||
dom.snapshotList.innerHTML = "";
|
||||
const snapshots = apiState.catalog?.creative_snapshots || [];
|
||||
if (!snapshots.length) {
|
||||
dom.snapshotList.innerHTML =
|
||||
'<div class="empty-state">No creative snapshots saved yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
snapshots.forEach((snapshot) => {
|
||||
const card = document.createElement("article");
|
||||
card.className = "snapshot-card";
|
||||
card.innerHTML = `
|
||||
<div class="snapshot-card-header">
|
||||
<div>
|
||||
<strong>${snapshot.label || snapshot.snapshot_id}</strong>
|
||||
<div class="preview-meta">${snapshot.snapshot_id}</div>
|
||||
</div>
|
||||
<button type="button" class="ghost-button">Recall</button>
|
||||
</div>
|
||||
<div class="snapshot-meta-row">
|
||||
<span class="meta-chip">${snapshot.pattern_id}</span>
|
||||
<span class="meta-chip">${snapshot.transition_style}</span>
|
||||
<span class="meta-chip">${snapshot.transition_duration_ms} ms</span>
|
||||
<span class="meta-chip">${snapshot.target_group || "all_panels"}</span>
|
||||
</div>
|
||||
`;
|
||||
card.querySelector("button").addEventListener("click", () =>
|
||||
sendCommand({
|
||||
type: "recall_creative_snapshot",
|
||||
payload: { snapshot_id: snapshot.snapshot_id },
|
||||
})
|
||||
);
|
||||
dom.snapshotList.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSceneParameters(scene) {
|
||||
dom.sceneParams.innerHTML = "";
|
||||
const parameters = scene.parameters || [];
|
||||
@@ -417,7 +545,7 @@
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const preview = apiState.snapshot?.preview;
|
||||
const preview = apiState.previewResponse?.preview;
|
||||
dom.previewGrid.innerHTML = "";
|
||||
if (!preview?.panels?.length) {
|
||||
dom.previewGrid.innerHTML =
|
||||
@@ -425,6 +553,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`;
|
||||
const panels = [...preview.panels].sort(comparePreviewPanels);
|
||||
panels.forEach((panel) => {
|
||||
const card = document.createElement("article");
|
||||
@@ -439,6 +568,7 @@
|
||||
<strong>${panel.energy_percent}%</strong>
|
||||
</div>
|
||||
<div class="preview-swatch"></div>
|
||||
<div class="energy-bar"><span style="--energy-width: ${panel.energy_percent}%"></span></div>
|
||||
<div class="sample-row">
|
||||
${panel.sample_led_hex
|
||||
.map(
|
||||
@@ -452,11 +582,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummaryCards(state, generatedAtMillis) {
|
||||
function renderSummaryCards(state) {
|
||||
const scene = state.active_scene;
|
||||
const global = state.global;
|
||||
const engine = state.engine;
|
||||
const nodeStats = summarizeNodes(state.nodes || []);
|
||||
const creativeSnapshotCount = (apiState.catalog?.creative_snapshots || []).length;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
@@ -467,11 +598,11 @@
|
||||
{
|
||||
label: "Group Target",
|
||||
value: scene.target_group || "all_panels",
|
||||
detail: `${(apiState.groups || []).length} groups available`,
|
||||
detail: `${(apiState.catalog?.groups || []).length} groups available`,
|
||||
},
|
||||
{
|
||||
label: "Transition",
|
||||
value: `${global.transition_duration_ms} ms`,
|
||||
value: `${global.transition_style} / ${global.transition_duration_ms} ms`,
|
||||
detail: engine.active_transition
|
||||
? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%`
|
||||
: "idle",
|
||||
@@ -492,9 +623,9 @@
|
||||
detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`,
|
||||
},
|
||||
{
|
||||
label: "Preview Timestamp",
|
||||
value: `${generatedAtMillis} ms`,
|
||||
detail: `${state.system.schema_version} schema`,
|
||||
label: "Creative Snapshots",
|
||||
value: `${creativeSnapshotCount}`,
|
||||
detail: `${(apiState.catalog?.presets || []).length} presets in library`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -512,28 +643,55 @@
|
||||
}
|
||||
|
||||
function renderSnapshotJson() {
|
||||
dom.snapshotJson.textContent = apiState.snapshot
|
||||
? JSON.stringify(apiState.snapshot, null, 2)
|
||||
: "No snapshot loaded.";
|
||||
dom.snapshotJson.textContent = JSON.stringify(buildComposedSnapshot(), null, 2);
|
||||
}
|
||||
|
||||
function buildComposedSnapshot() {
|
||||
return {
|
||||
api_version: apiState.stateResponse?.api_version || apiState.previewResponse?.api_version || "v1",
|
||||
generated_at_millis:
|
||||
apiState.previewResponse?.generated_at_millis ||
|
||||
apiState.stateResponse?.generated_at_millis ||
|
||||
0,
|
||||
state: apiState.stateResponse?.state || null,
|
||||
preview: apiState.previewResponse?.preview || null,
|
||||
catalog: apiState.catalog || null,
|
||||
};
|
||||
}
|
||||
|
||||
function pushEvent(entry) {
|
||||
apiState.events.unshift(entry);
|
||||
apiState.events = apiState.events.slice(0, 12);
|
||||
apiState.events.unshift({
|
||||
kind: entry.kind || "info",
|
||||
code: entry.code || null,
|
||||
...entry,
|
||||
});
|
||||
apiState.events = apiState.events.slice(0, 50);
|
||||
renderEvents();
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
if (!apiState.events.length) {
|
||||
dom.eventList.innerHTML = '<div class="empty-state">No websocket notices yet.</div>';
|
||||
const kindFilter = dom.eventKindFilter.value;
|
||||
const searchFilter = dom.eventSearchFilter.value.trim().toLowerCase();
|
||||
const filtered = apiState.events.filter((entry) => {
|
||||
const kindMatches = kindFilter === "all" || entry.kind === kindFilter;
|
||||
const searchMatches =
|
||||
!searchFilter ||
|
||||
(entry.message || "").toLowerCase().includes(searchFilter) ||
|
||||
(entry.code || "").toLowerCase().includes(searchFilter);
|
||||
return kindMatches && searchMatches;
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
dom.eventList.innerHTML = '<div class="empty-state">No events match the current filter.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
dom.eventList.innerHTML = apiState.events
|
||||
dom.eventList.innerHTML = filtered
|
||||
.map(
|
||||
(entry) => `
|
||||
<article class="event-item">
|
||||
<article class="event-item event-${entry.kind}">
|
||||
<div class="event-meta">${entry.at}</div>
|
||||
${entry.code ? `<span class="event-code">${entry.code}</span>` : ""}
|
||||
<strong>${entry.message}</strong>
|
||||
</article>
|
||||
`
|
||||
|
||||
@@ -45,11 +45,20 @@
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Transition</span>
|
||||
<span>Transition Duration</span>
|
||||
<input id="transition-slider" type="range" min="0" max="3000" step="10" />
|
||||
<strong id="transition-value">0 ms</strong>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Transition Style</span>
|
||||
<select id="transition-style-select">
|
||||
<option value="snap">Snap</option>
|
||||
<option value="crossfade">Crossfade</option>
|
||||
<option value="chase">Chase</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Master Brightness</span>
|
||||
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
|
||||
@@ -72,14 +81,65 @@
|
||||
<div id="preset-list" class="pill-row"></div>
|
||||
</div>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Preset Capture</h3>
|
||||
<p>Store or overwrite the current scene as a reusable preset through the same API.</p>
|
||||
</div>
|
||||
<div class="capture-grid">
|
||||
<label class="field">
|
||||
<span>Preset ID</span>
|
||||
<input id="preset-id-input" type="text" placeholder="e.g. sunset_chase" />
|
||||
</label>
|
||||
<label class="field inline-checkbox">
|
||||
<span>Overwrite Existing</span>
|
||||
<input id="preset-overwrite-input" type="checkbox" />
|
||||
</label>
|
||||
<button id="save-preset-button" class="ghost-button" type="button">
|
||||
Save Current Scene As Preset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Groups</h3>
|
||||
<p>Focus looks on a subset while keeping the core scene model shared.</p>
|
||||
</div>
|
||||
<input
|
||||
id="group-filter-input"
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="Filter groups by id or tag"
|
||||
/>
|
||||
<div id="group-list" class="pill-row"></div>
|
||||
</div>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Creative Snapshots</h3>
|
||||
<p>Capture exploratory variants without replacing curated presets.</p>
|
||||
</div>
|
||||
<div class="capture-grid">
|
||||
<label class="field">
|
||||
<span>Snapshot ID</span>
|
||||
<input id="snapshot-id-input" type="text" placeholder="e.g. variant_afterglow" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Label</span>
|
||||
<input id="snapshot-label-input" type="text" placeholder="Readable label" />
|
||||
</label>
|
||||
<label class="field inline-checkbox">
|
||||
<span>Overwrite Existing</span>
|
||||
<input id="snapshot-overwrite-input" type="checkbox" />
|
||||
</label>
|
||||
<button id="save-snapshot-button" class="ghost-button" type="button">
|
||||
Save Creative Snapshot
|
||||
</button>
|
||||
</div>
|
||||
<div id="snapshot-list" class="snapshot-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Scene Parameters</h3>
|
||||
@@ -111,6 +171,20 @@
|
||||
<h2>Event Stream</h2>
|
||||
<p>Recent notices from the websocket feed.</p>
|
||||
</div>
|
||||
<div class="event-filter-bar">
|
||||
<select id="event-kind-filter">
|
||||
<option value="all">All kinds</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<input
|
||||
id="event-search-filter"
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="Filter by code or message"
|
||||
/>
|
||||
</div>
|
||||
<div id="event-list" class="event-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -202,6 +202,7 @@ body::after {
|
||||
}
|
||||
|
||||
.control-grid,
|
||||
.capture-grid,
|
||||
.parameter-grid,
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
@@ -212,6 +213,11 @@ body::after {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.capture-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.parameter-grid,
|
||||
.summary-cards {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
@@ -295,11 +301,26 @@ input[type="text"] {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.inline-checkbox {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.inline-checkbox input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 4px 0 0;
|
||||
accent-color: var(--accent-cool);
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
.preset-button,
|
||||
.group-button {
|
||||
@@ -374,6 +395,21 @@ input[type="range"] {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
|
||||
.energy-bar {
|
||||
height: 8px;
|
||||
margin-top: 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(31, 36, 36, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.energy-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--energy-width, 0%);
|
||||
background: linear-gradient(90deg, var(--preview-color, #999999), rgba(255, 255, 255, 0.84));
|
||||
}
|
||||
|
||||
.sample-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -429,11 +465,84 @@ input[type="range"] {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-filter-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event-item.event-info strong {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.event-item.event-warning strong {
|
||||
color: #a7631c;
|
||||
}
|
||||
|
||||
.event-item.event-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.event-code {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(31, 36, 36, 0.08);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.snapshot-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.snapshot-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid rgba(56, 63, 61, 0.08);
|
||||
}
|
||||
|
||||
.snapshot-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.snapshot-card-header strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.snapshot-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(31, 36, 36, 0.08);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.event-item strong {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
@@ -460,7 +569,8 @@ input[type="range"] {
|
||||
@media (max-width: 1080px) {
|
||||
.layout,
|
||||
.hero,
|
||||
.control-grid {
|
||||
.control-grid,
|
||||
.event-filter-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user