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 = ` `; 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);