Files
portal/src/static/js/components/portal-sequence-editor.js
T
jimmyandCursor 3d444d1cb5 Ship the portal FastAPI controller with presets, sequences, and live audio.
Move the app under src/, add ring-spanning patterns with shared fonts and
beat-driven motion, and wire global audio settings into the web UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 00:59:03 +12:00

358 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const EDITOR_STYLES = `
:host {
display: none;
position: fixed;
inset: 0;
z-index: 40;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(4, 6, 12, 0.72);
font-family: system-ui, sans-serif;
color: var(--text, #e8ecf7);
}
:host([open]) {
display: flex;
}
.dialog {
width: min(100%, 460px);
max-height: min(92vh, 720px);
overflow: auto;
background: #12151f;
border: 1px solid #2a3044;
border-radius: 12px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid #2a3044;
position: sticky;
top: 0;
background: #12151f;
z-index: 1;
}
.head h2 {
margin: 0;
font-size: 1.05rem;
}
.body {
padding: 0.85rem 1rem 1rem;
}
label {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0.75rem 0 0.35rem;
font-size: 0.8rem;
color: #8b93a8;
}
select, input[type="text"], input[type="number"], button {
width: 100%;
background: #1a1f2e;
color: inherit;
border: 1px solid #2a3044;
border-radius: 6px;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
}
button {
cursor: pointer;
background: #243049;
}
button:hover { border-color: #5b8cff; }
button.primary {
background: #2a3f6e;
border-color: #5b8cff;
}
button.danger {
background: #3a1f28;
border-color: #7a3545;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.85rem 0 0.35rem;
font-size: 0.85rem;
color: #e8ecf7;
}
.check input {
width: auto;
}
.steps {
display: grid;
gap: 0.5rem;
margin-top: 0.5rem;
}
.step {
display: grid;
grid-template-columns: 1fr 4.5rem auto;
gap: 0.35rem;
align-items: center;
}
.step input[type="number"] {
text-align: right;
}
.step .remove {
width: auto;
padding: 0.45rem 0.55rem;
}
.hint {
margin: 0.35rem 0 0;
font-size: 0.75rem;
color: #8b93a8;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 1rem;
}
.actions-wide {
display: grid;
gap: 0.5rem;
margin-top: 0.5rem;
}
.empty {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: #8b93a8;
}
`;
/**
* Modal editor: create/edit a timed sequence of presets.
* Events:
* - portal-sequence-editor-save { draft, id|null }
* - portal-sequence-editor-delete { id, name }
* - portal-sequence-editor-close
*/
export class PortalSequenceEditor extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').Preset[]} */
this._presets = [];
/** @type {string | null} */
this._editId = null;
this._name = "";
this._loop = true;
/** @type {import('../api.js').SequenceStep[]} */
this._steps = [];
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${EDITOR_STYLES}</style>
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="sequence-title">
<div class="head">
<h2 id="sequence-title">Sequence</h2>
<button type="button" data-action="close">Close</button>
</div>
<div class="body">
<label for="name">Name</label>
<input id="name" type="text" maxlength="40" placeholder="Sequence name" />
<label class="check">
<input id="loop" type="checkbox" checked />
Loop sequence
</label>
<label>Steps</label>
<p class="hint">Each step plays a preset for a number of seconds.</p>
<div class="steps" id="steps"></div>
<p class="empty" id="steps-empty" hidden>Add at least one step.</p>
<div class="actions-wide">
<button type="button" data-action="add-step">Add step</button>
</div>
<div class="actions">
<button type="button" class="primary" data-action="save">Save</button>
<button type="button" data-action="close-bottom">Cancel</button>
</div>
<div class="actions-wide">
<button type="button" class="danger" data-action="delete" hidden>Delete</button>
</div>
</div>
</div>
`;
this._titleEl = root.querySelector("#sequence-title");
this._nameEl = root.querySelector("#name");
this._loopEl = root.querySelector("#loop");
this._stepsEl = root.querySelector("#steps");
this._stepsEmpty = root.querySelector("#steps-empty");
this._deleteEl = root.querySelector('[data-action="delete"]');
this._nameEl.addEventListener("input", () => {
this._name = this._nameEl.value;
});
this._loopEl.addEventListener("change", () => {
this._loop = this._loopEl.checked;
});
root.querySelector('[data-action="close"]').addEventListener("click", () => this.close());
root.querySelector('[data-action="close-bottom"]').addEventListener("click", () => this.close());
root.querySelector('[data-action="save"]').addEventListener("click", () => this._emitSave());
root.querySelector('[data-action="add-step"]').addEventListener("click", () => {
const first = this._presets[0];
if (!first) return;
this._steps.push({ preset_id: first.id, duration_s: 8 });
this._renderSteps();
});
this._deleteEl.addEventListener("click", () => {
if (!this._editId) return;
this.dispatchEvent(
new CustomEvent("portal-sequence-editor-delete", {
bubbles: true,
composed: true,
detail: { id: this._editId, name: this._name || "sequence" },
}),
);
});
root.addEventListener("click", (e) => {
if (e.target === root) this.close();
});
}
/** @param {import('../api.js').Preset[]} presets */
setPresets(presets) {
this._presets = presets || [];
if (this.hasAttribute("open")) this._renderSteps();
}
/**
* @param {{ presets: import('../api.js').Preset[], sequence?: import('../api.js').Sequence | null }} opts
*/
open({ presets, sequence = null } = {}) {
if (!this.shadowRoot) this.connectedCallback();
if (presets) this.setPresets(presets);
this._editId = sequence?.id || null;
this._titleEl.textContent = this._editId ? "Edit sequence" : "New sequence";
this._deleteEl.hidden = !this._editId;
if (sequence) {
this._name = sequence.name || "";
this._loop = sequence.loop !== false;
this._steps = (sequence.steps || []).map((step) => ({
preset_id: String(step.preset_id),
duration_s: Number(step.duration_s) || 8,
}));
} else {
this._name = "";
this._loop = true;
const first = this._presets[0];
this._steps = first ? [{ preset_id: first.id, duration_s: 8 }] : [];
}
this._nameEl.value = this._name;
this._loopEl.checked = this._loop;
this._renderSteps();
this.setAttribute("open", "");
this._nameEl.focus();
}
close() {
this.removeAttribute("open");
this.dispatchEvent(
new CustomEvent("portal-sequence-editor-close", { bubbles: true, composed: true }),
);
}
get isOpen() {
return this.hasAttribute("open");
}
_renderSteps() {
if (!this._stepsEl) return;
this._stepsEl.innerHTML = "";
this._stepsEmpty.hidden = this._steps.length > 0 || this._presets.length === 0;
if (!this._presets.length) {
this._stepsEmpty.hidden = false;
this._stepsEmpty.textContent = "Create a preset before building a sequence.";
return;
}
this._stepsEmpty.textContent = "Add at least one step.";
this._steps.forEach((step, index) => {
const row = document.createElement("div");
row.className = "step";
const select = document.createElement("select");
for (const preset of this._presets) {
const opt = document.createElement("option");
opt.value = preset.id;
opt.textContent = preset.name;
select.appendChild(opt);
}
select.value = step.preset_id;
if (![...select.options].some((o) => o.value === step.preset_id) && this._presets[0]) {
select.value = this._presets[0].id;
step.preset_id = this._presets[0].id;
}
select.addEventListener("change", () => {
this._steps[index].preset_id = select.value;
});
const duration = document.createElement("input");
duration.type = "number";
duration.min = "0.5";
duration.max = "600";
duration.step = "0.5";
duration.value = String(step.duration_s);
duration.title = "Duration (seconds)";
duration.addEventListener("change", () => {
let value = Number(duration.value);
if (!Number.isFinite(value) || value < 0.5) value = 0.5;
if (value > 600) value = 600;
this._steps[index].duration_s = value;
duration.value = String(value);
});
const remove = document.createElement("button");
remove.type = "button";
remove.className = "remove";
remove.textContent = "×";
remove.title = "Remove step";
remove.addEventListener("click", () => {
this._steps.splice(index, 1);
this._renderSteps();
});
row.append(select, duration, remove);
this._stepsEl.appendChild(row);
});
}
_draft() {
const name = this._nameEl.value.trim() || "Sequence";
return {
name,
loop: this._loopEl.checked,
steps: this._steps.map((step) => ({
preset_id: step.preset_id,
duration_s: Number(step.duration_s) || 8,
})),
};
}
_emitSave() {
const draft = this._draft();
if (!draft.steps.length) {
this._stepsEmpty.hidden = false;
return;
}
this.dispatchEvent(
new CustomEvent("portal-sequence-editor-save", {
bubbles: true,
composed: true,
detail: { id: this._editId, draft },
}),
);
}
}
customElements.define("portal-sequence-editor", PortalSequenceEditor);