Files
obs-config/webapp/resources/views/onboard.blade.php
Jakub Zych 4ceff4015a webapp: add /onboard wizard — web equivalent of php artisan rig:loading
Browser-based Project Loading manifest builder at http://127.0.0.1:1118/onboard.
Reuses the existing terminal/CRT styling (hud.css palette + scanlines +
phosphor glow) so it doesn't feel like a separate UI:

 - GET  /onboard           — the wizard, seeded from current loading.json
 - GET  /onboard/search?q= — type-ahead Twitch search/categories proxy
 - POST /onboard           — writes storage/data/loading.json, optionally
                              pushes the title + category via TwitchHelix

UX: prompt-line inputs, ↑/↓/Enter on the search dropdown, stepper for the
countdown, ENABLED/DISABLED toggles for camera + microphone, a live
right-pane preview that renders the same lines the loading overlay's
terminal will type. CSRF-exempt POST so the page can submit JSON without
a token round-trip.

Falls back gracefully when TWITCH_CLIENT_ID / TWITCH_TOKEN are absent —
the search is disabled and the manifest still saves locally.
2026-05-21 13:49:19 +02:00

778 lines
26 KiB
PHP
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.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OPHI-118 / onboard</title>
<link rel="stylesheet" href="{{ asset('css/hud.css') }}">
<style>
/* Onboard wizard is the only page that's not an OBS browser_source — it
renders in a real desktop browser at arbitrary viewport sizes. So we
break from the canvas-pixel design language and use a centered
terminal-card layout with breathing room. */
body.overlay-body {
padding: 24px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 24px;
min-height: 100vh;
height: auto;
overflow: auto;
}
.wizard {
width: min(960px, 100%);
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 360px;
gap: 22px;
align-items: start;
}
@media (max-width: 800px) {
.wizard { grid-template-columns: 1fr; }
}
.panel {
position: relative;
background: var(--term-bg);
border: 2px solid var(--term-edge);
border-radius: 4px;
padding: 28px 30px;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
box-shadow:
0 0 60px rgba(80, 220, 100, 0.10),
inset 0 0 90px rgba(0, 30, 0, 0.55);
font-family: var(--mono);
overflow: visible;
}
.panel::after {
content: '';
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,0.22) 3px,
rgba(0,0,0,0.22) 4px
);
opacity: 0.45;
mix-blend-mode: multiply;
border-radius: 2px;
}
.panel > .pane-tab {
position: absolute;
top: -14px;
left: 28px;
padding: 2px 14px;
background: var(--bg);
color: var(--term-fg-bright);
font-size: 14px;
letter-spacing: 0.32em;
text-shadow: 0 0 6px var(--term-glow);
z-index: 1;
}
.panel > * { position: relative; z-index: 0; }
.form-row {
display: grid;
grid-template-columns: 14em 1fr;
gap: 14px;
align-items: start;
margin-bottom: 18px;
}
.form-row > label {
color: var(--term-fg-dim);
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 14px;
padding-top: 8px;
}
.form-row > .field { min-width: 0; }
.form-row .hint {
grid-column: 2;
color: var(--term-fg-dim);
font-size: 12px;
letter-spacing: 0.08em;
margin-top: 6px;
}
.form-row.error > label { color: var(--accent); }
.form-row.error .field input,
.form-row.error .field .toggle { border-color: var(--accent); }
.prompt-line {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--term-edge);
background: rgba(0, 30, 0, 0.45);
border-radius: 3px;
padding: 8px 12px;
transition: border-color .15s ease, box-shadow .15s ease;
}
.prompt-line:focus-within {
border-color: var(--term-fg-bright);
box-shadow: 0 0 0 1px var(--term-fg-bright), 0 0 18px rgba(95, 220, 98, 0.25);
}
.prompt-line .caret {
color: var(--term-fg-bright);
font-size: 14px;
}
.prompt-line input {
flex: 1 1 auto;
background: transparent;
border: 0;
outline: 0;
color: var(--term-fg-bright);
font: inherit;
font-size: 15px;
padding: 2px 0;
text-shadow: 0 0 6px var(--term-glow);
}
.prompt-line input::placeholder {
color: var(--term-fg-dim);
text-shadow: none;
}
/* Twitch search dropdown — appears below the game input. */
.search-wrap { position: relative; }
.search-results {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
background: var(--term-bg);
border: 1px solid var(--term-edge);
border-radius: 3px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 10;
max-height: 260px;
overflow-y: auto;
display: none;
}
.search-results.on { display: block; }
.search-results .row {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 12px;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background .1s ease;
border-bottom: 1px solid rgba(80, 220, 100, 0.10);
}
.search-results .row:last-child { border-bottom: 0; }
.search-results .row:hover,
.search-results .row.active {
background: rgba(95, 220, 98, 0.10);
}
.search-results .row img {
width: 36px;
height: 48px;
object-fit: cover;
background: rgba(0,0,0,0.4);
border: 1px solid var(--term-edge);
}
.search-results .row .name { color: var(--term-fg-bright); }
.search-results .row .id { color: var(--term-fg-dim); font-size: 12px; }
.search-results .row .empty,
.search-results .row .error { color: var(--term-fg-dim); grid-column: 1 / -1; padding: 4px 0; }
.search-results .row .error { color: var(--accent); }
/* Number input — chevron buttons styled like terminal buttons. */
.stepper {
display: inline-grid;
grid-template-columns: 32px 5ch 32px;
align-items: stretch;
border: 1px solid var(--term-edge);
border-radius: 3px;
overflow: hidden;
background: rgba(0, 30, 0, 0.45);
}
.stepper button {
background: transparent;
border: 0;
color: var(--term-fg-bright);
font: inherit;
font-size: 16px;
cursor: pointer;
transition: background .12s ease;
}
.stepper button:hover { background: rgba(95, 220, 98, 0.15); }
.stepper input {
background: transparent;
border: 0;
outline: 0;
color: var(--term-fg-bright);
font: inherit;
font-size: 15px;
text-align: center;
text-shadow: 0 0 6px var(--term-glow);
-moz-appearance: textfield;
}
.stepper input::-webkit-outer-spin-button,
.stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
/* Toggle for camera / microphone — labeled ENABLED / DISABLED, swaps color. */
.toggle {
display: inline-flex;
border: 1px solid var(--term-edge);
border-radius: 3px;
overflow: hidden;
background: rgba(0, 30, 0, 0.45);
user-select: none;
}
.toggle button {
background: transparent;
border: 0;
color: var(--term-fg-dim);
font: inherit;
font-size: 14px;
letter-spacing: 0.18em;
padding: 8px 16px;
cursor: pointer;
transition: background .12s ease, color .12s ease;
}
.toggle button.on {
background: rgba(95, 220, 98, 0.18);
color: var(--term-fg-bright);
text-shadow: 0 0 6px var(--term-glow);
}
.toggle button.on.off-state {
background: rgba(230, 58, 46, 0.18);
color: var(--accent);
text-shadow: 0 0 6px rgba(230, 58, 46, 0.6);
}
/* Twitch push checkbox — boxy custom-styled. */
.check-row {
display: flex;
align-items: center;
gap: 12px;
color: var(--term-fg);
}
.check-row input[type=checkbox] { display: none; }
.check-row .checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px; height: 18px;
border: 1px solid var(--term-edge);
border-radius: 2px;
background: rgba(0, 30, 0, 0.45);
font-size: 14px;
color: var(--term-fg-bright);
cursor: pointer;
transition: border-color .12s ease;
}
.check-row.on .checkbox { border-color: var(--term-fg-bright); }
.check-row.on .checkbox::before { content: '✓'; }
.check-row.disabled { color: var(--term-fg-dim); }
.check-row.disabled .checkbox { cursor: not-allowed; opacity: 0.5; }
/* Submit row — primary action plus a small "save only" secondary. */
.actions {
display: flex;
justify-content: flex-end;
gap: 14px;
margin-top: 8px;
}
.btn {
background: rgba(0, 30, 0, 0.55);
color: var(--term-fg-bright);
font: inherit;
font-size: 14px;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 12px 22px;
border: 1px solid var(--term-fg-bright);
border-radius: 3px;
cursor: pointer;
text-shadow: 0 0 6px var(--term-glow);
box-shadow: 0 0 14px rgba(95, 220, 98, 0.12);
transition: background .12s ease, box-shadow .12s ease;
}
.btn:hover {
background: rgba(95, 220, 98, 0.18);
box-shadow: 0 0 22px rgba(95, 220, 98, 0.28);
}
.btn.secondary {
color: var(--term-fg);
border-color: var(--term-edge);
box-shadow: none;
}
.btn:disabled {
color: var(--term-fg-dim);
border-color: var(--term-edge);
box-shadow: none;
cursor: not-allowed;
background: rgba(0, 30, 0, 0.3);
}
.btn .spinner {
display: inline-block;
width: 1ch;
text-align: center;
margin-right: 6px;
}
/* Preview pane (right column) renders the same lines the loading
overlay would type, so the streamer can see exactly what will show up. */
.preview-pane .terminal-mock {
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--term-edge);
border-radius: 3px;
padding: 14px 16px;
font-size: 13px;
line-height: 1.55;
min-height: 240px;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
}
.preview-pane .terminal-mock .prompt { color: var(--term-fg-bright); }
.preview-pane .terminal-mock .dim { color: var(--term-fg-dim); }
.preview-pane .terminal-mock .gap { height: 0.5em; }
.preview-pane h3 {
color: var(--hud);
letter-spacing: 0.22em;
font-size: 13px;
margin-bottom: 12px;
text-transform: uppercase;
}
.preview-pane .footnote {
margin-top: 14px;
font-size: 12px;
color: var(--term-fg-dim);
letter-spacing: 0.08em;
}
.preview-pane .footnote a { color: var(--hud); text-decoration: none; }
.preview-pane .footnote a:hover { text-decoration: underline; }
/* Toast / result line shown after submit. */
#toast {
margin-top: 12px;
padding: 12px 14px;
border: 1px solid var(--term-edge);
border-radius: 3px;
background: rgba(0, 30, 0, 0.45);
color: var(--term-fg);
font-size: 13px;
display: none;
}
#toast.on { display: block; }
#toast.err { border-color: var(--accent); color: var(--accent); }
#toast.ok { border-color: var(--term-fg-bright); }
#toast pre { white-space: pre-wrap; margin: 6px 0 0; color: var(--term-fg-dim); }
/* Header strip mirrors the on-stream HUD chrome. */
.hud {
font-size: 16px;
padding: 0 4px;
}
.hud .group { gap: 18px; }
/* Footer note. */
.foot-note {
text-align: center;
color: var(--term-fg-dim);
font-size: 12px;
letter-spacing: 0.18em;
}
</style>
</head>
<body class="overlay-body">
<header class="hud hud--onair">
<div class="group">
<span><span class="led"></span>ONBOARD</span>
<span>OPHI-118 // PROJECT MANIFEST</span>
</div>
<div class="group">
<span class="dim">RIG</span>
<span id="rig-name">{{ $telemetry['rig'] ?? '—' }}</span>
</div>
<div class="group">
<span class="dim">UTC</span>
<span id="clock">--:--:--</span>
</div>
</header>
<main class="wizard">
<section class="panel" id="form-panel">
<span class="pane-tab"> PROJECT LOAD </span>
<form id="onboard-form" novalidate>
<div class="form-row" id="row-game">
<label for="f-game">Game</label>
<div class="field search-wrap">
<div class="prompt-line">
<span class="caret"></span>
<input
type="text"
id="f-game"
name="game"
autocomplete="off"
spellcheck="false"
placeholder="start typing — e.g. Half-Life 2"
value="{{ $manifest['game'] ?? '' }}"
data-game-id="{{ $manifest['gameId'] ?? '' }}"
>
</div>
<div class="search-results" id="search-results" role="listbox"></div>
<div class="hint" id="game-hint">
@if (!$twitchEnabled)
<span style="color: var(--warn);"> TWITCH_CLIENT_ID / TWITCH_TOKEN not set game search disabled. Manifest will still save locally.</span>
@else
Twitch search runs as you type. Pick the matching category to lock the game id.
@endif
</div>
</div>
</div>
<div class="form-row">
<label for="f-subtitle">Subtitle</label>
<div class="field">
<div class="prompt-line">
<span class="caret"></span>
<input
type="text"
id="f-subtitle"
name="subtitle"
autocomplete="off"
spellcheck="false"
placeholder="mode / episode / note (optional)"
value="{{ $manifest['subtitle'] ?? '' }}"
>
</div>
<div class="hint">Becomes the Twitch stream title when "push to Twitch" is on (else falls back to the game name).</div>
</div>
</div>
<div class="form-row">
<label for="f-countdown">Countdown</label>
<div class="field">
<div class="stepper" role="group" aria-label="countdown minutes">
<button type="button" data-step="-1" aria-label="1"></button>
<input
type="number" id="f-countdown" name="countdownMin" min="0" max="120"
value="{{ $manifest['countdownMin'] ?? 5 }}"
>
<button type="button" data-step="+1" aria-label="+1">+</button>
</div>
<span style="margin-left: 12px; color: var(--term-fg-dim); letter-spacing: 0.12em;">minutes</span>
<div class="hint">"Starting in MM:00 — Transmission Incoming" countdown on the Project Loading scene.</div>
</div>
</div>
<div class="form-row">
<label>Camera</label>
<div class="field">
<div class="toggle" data-toggle="camera"
data-value="{{ ($manifest['camera'] ?? true) ? 'true' : 'false' }}">
<button type="button" data-val="true" class="{{ ($manifest['camera'] ?? true) ? 'on' : '' }}">ENABLED</button>
<button type="button" data-val="false" class="off-state {{ ($manifest['camera'] ?? true) ? '' : 'on' }}">DISABLED</button>
</div>
<input type="hidden" name="camera" value="{{ ($manifest['camera'] ?? true) ? '1' : '0' }}">
</div>
</div>
<div class="form-row">
<label>Microphone</label>
<div class="field">
<div class="toggle" data-toggle="microphone"
data-value="{{ ($manifest['microphone'] ?? true) ? 'true' : 'false' }}">
<button type="button" data-val="true" class="{{ ($manifest['microphone'] ?? true) ? 'on' : '' }}">ENABLED</button>
<button type="button" data-val="false" class="off-state {{ ($manifest['microphone'] ?? true) ? '' : 'on' }}">DISABLED</button>
</div>
<input type="hidden" name="microphone" value="{{ ($manifest['microphone'] ?? true) ? '1' : '0' }}">
</div>
</div>
<div class="form-row">
<label>Push to Twitch</label>
<div class="field">
<label class="check-row {{ $twitchEnabled ? 'on' : 'disabled' }}" id="push-row">
<input type="checkbox" name="pushTwitch" {{ $twitchEnabled ? 'checked' : '' }} {{ $twitchEnabled ? '' : 'disabled' }}>
<span class="checkbox"></span>
<span>Update channel title + category via Helix on save</span>
</label>
<div class="hint">Uses TWITCH_CLIENT_ID / TWITCH_TOKEN with the channel:manage:broadcast scope.</div>
</div>
</div>
<div class="actions">
<button type="submit" class="btn" id="submit-btn">
<span class="spinner" id="spinner"></span>WRITE MANIFEST
</button>
</div>
<div id="toast" role="status" aria-live="polite"></div>
</form>
</section>
<aside class="panel preview-pane">
<span class="pane-tab"> PREVIEW </span>
<h3>terminal output (loading scene)</h3>
<div class="terminal-mock" id="preview"></div>
<div class="footnote">
Rendered live as you edit. Mirrors the
<a href="/loading" target="_blank">/loading</a> overlay's terminal block.
Tracks in library: <strong>{{ $trackCount }}</strong>.
</div>
</aside>
</main>
<footer class="foot-note">
saves to webapp/storage/data/loading.json · OBS browser_source refresh-cache picks it up
</footer>
<script src="{{ asset('js/hud.js') }}"></script>
<script>
'use strict';
// ─── small utilities ───────────────────────────────────────────────────
const $ = sel => document.querySelector(sel);
const $$ = sel => Array.from(document.querySelectorAll(sel));
const debounce = (fn, ms) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};
// ─── form state ────────────────────────────────────────────────────────
const form = $('#onboard-form');
const gameInput = $('#f-game');
const subInput = $('#f-subtitle');
const cdInput = $('#f-countdown');
const camHidden = form.querySelector('input[name="camera"]');
const micHidden = form.querySelector('input[name="microphone"]');
const pushInput = form.querySelector('input[name="pushTwitch"]');
const submitBtn = $('#submit-btn');
const spinner = $('#spinner');
const toast = $('#toast');
const preview = $('#preview');
const resultsEl = $('#search-results');
const rigName = $('#rig-name').textContent || 'UNKNOWN';
const TWITCH_ENABLED = @json($twitchEnabled);
// ─── toggle (camera / microphone) ──────────────────────────────────────
$$('.toggle').forEach(group => {
const hidden = form.querySelector(`input[name="${group.dataset.toggle}"]`);
group.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
const val = btn.dataset.val;
group.dataset.value = val;
group.querySelectorAll('button').forEach(b => {
b.classList.toggle('on', b.dataset.val === val);
});
hidden.value = val === 'true' ? '1' : '0';
renderPreview();
});
});
});
// ─── stepper (countdown) ───────────────────────────────────────────────
$$('.stepper button').forEach(btn => {
btn.addEventListener('click', () => {
const step = parseInt(btn.dataset.step, 10) || 0;
const cur = parseInt(cdInput.value, 10) || 0;
cdInput.value = Math.max(0, Math.min(120, cur + step));
renderPreview();
});
});
cdInput.addEventListener('input', renderPreview);
subInput.addEventListener('input', renderPreview);
// ─── push-to-twitch checkbox ───────────────────────────────────────────
const pushRow = $('#push-row');
pushInput.addEventListener('change', () => {
pushRow.classList.toggle('on', pushInput.checked);
});
// ─── twitch search ─────────────────────────────────────────────────────
let activeIdx = -1;
let matches = [];
function setGameId(id) {
gameInput.dataset.gameId = id || '';
}
function clearGameId() {
if (gameInput.dataset.gameId) setGameId('');
}
function renderMatches(rows) {
matches = rows;
activeIdx = -1;
if (!rows.length) {
resultsEl.innerHTML = '<div class="row"><span class="empty">no matches</span></div>';
resultsEl.classList.add('on');
return;
}
resultsEl.innerHTML = rows.map((r, i) => {
const box = (r.boxArt || '').replace(/-{width}x{height}/, '-72x96');
return `
<div class="row" data-i="${i}" role="option">
${box ? `<img src="${box}" alt="">` : '<span></span>'}
<div>
<div class="name">${escapeHtml(r.name)}</div>
<div class="id">id ${escapeHtml(r.id)}</div>
</div>
<span class="dim" style="color: var(--term-fg-dim); font-size: 11px;">↵ select</span>
</div>
`;
}).join('');
resultsEl.classList.add('on');
resultsEl.querySelectorAll('.row').forEach(row => {
row.addEventListener('click', () => pickMatch(parseInt(row.dataset.i, 10)));
});
}
function pickMatch(i) {
const m = matches[i];
if (!m) return;
gameInput.value = m.name;
setGameId(m.id);
resultsEl.classList.remove('on');
renderPreview();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[c]);
}
const runSearch = debounce(async (q) => {
if (!TWITCH_ENABLED) { resultsEl.classList.remove('on'); return; }
if (q.length < 2) { resultsEl.classList.remove('on'); return; }
try {
const r = await fetch(`/onboard/search?q=${encodeURIComponent(q)}`);
if (!r.ok) {
const err = await r.json().catch(() => ({}));
resultsEl.innerHTML = `<div class="row"><span class="error">${escapeHtml(err.error || 'search failed')}</span></div>`;
resultsEl.classList.add('on');
return;
}
const body = await r.json();
renderMatches(body.matches || []);
} catch (e) {
resultsEl.innerHTML = `<div class="row"><span class="error">${escapeHtml(e.message)}</span></div>`;
resultsEl.classList.add('on');
}
}, 220);
gameInput.addEventListener('input', () => {
clearGameId();
runSearch(gameInput.value.trim());
renderPreview();
});
gameInput.addEventListener('keydown', (e) => {
if (!resultsEl.classList.contains('on')) return;
const rows = resultsEl.querySelectorAll('.row[data-i]');
if (!rows.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIdx = (activeIdx + 1) % rows.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIdx = (activeIdx - 1 + rows.length) % rows.length;
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault();
pickMatch(activeIdx);
return;
} else if (e.key === 'Escape') {
resultsEl.classList.remove('on');
return;
} else {
return;
}
rows.forEach((r, i) => r.classList.toggle('active', i === activeIdx));
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-wrap')) resultsEl.classList.remove('on');
});
// ─── live preview ──────────────────────────────────────────────────────
function renderPreview() {
const game = gameInput.value.trim();
const sub = subInput.value.trim();
const cd = parseInt(cdInput.value, 10) || 0;
const cam = camHidden.value === '1';
const mic = micHidden.value === '1';
const onOff = b => b ? 'ENABLED' : 'DISABLED';
const pad = n => String(n).padStart(2, '0');
const rig = (rigName || '?').toUpperCase();
const PROMPT = 'OPHI-118://> ';
const rows = [
{ prompt: PROMPT, text: 'loadproject --manifest' },
{ text: `detected rig :: ${rig} - initializing transmission...` },
{ gap: true },
{ text: `target :: ${(game || '—').toUpperCase()}` },
...(sub ? [{ text: `mode :: ${sub.toUpperCase()}` }] : []),
{ text: `camera :: ${onOff(cam)}` },
{ text: `microphone :: ${onOff(mic)}` },
{ text: `countdown :: ${pad(cd)}:00` },
{ gap: true },
{ text: 'all systems nominal', dim: true },
{ text: 'READY.' },
];
preview.innerHTML = rows.map(r => {
if (r.gap) return '<div class="gap"></div>';
const cls = r.dim ? ' dim' : '';
if (r.prompt) {
return `<div><span class="prompt">${escapeHtml(r.prompt)}</span><span class="${cls.trim()}">${escapeHtml(r.text)}</span></div>`;
}
return `<div><span class="prompt">&gt; </span><span class="${cls.trim()}">${escapeHtml(r.text)}</span></div>`;
}).join('');
}
// ─── submit ────────────────────────────────────────────────────────────
form.addEventListener('submit', async (e) => {
e.preventDefault();
toast.classList.remove('on', 'ok', 'err');
submitBtn.disabled = true;
spinner.textContent = '⟳';
const payload = {
game: gameInput.value.trim(),
gameId: gameInput.dataset.gameId || null,
subtitle: subInput.value.trim() || null,
countdownMin: parseInt(cdInput.value, 10) || 0,
camera: camHidden.value === '1',
microphone: micHidden.value === '1',
pushTwitch: pushInput.checked,
};
try {
const r = await fetch('/onboard', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload),
});
const body = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = body.message
? body.message + (body.errors ? '\n' + JSON.stringify(body.errors, null, 2) : '')
: `HTTP ${r.status}`;
toast.className = 'on err';
toast.innerHTML = `✗ submit failed<pre>${escapeHtml(msg)}</pre>`;
return;
}
const lines = [`✓ wrote ${body.path}`];
if (body.twitch) lines.push(`twitch: ${body.twitch}`);
lines.push('→ refresh the Project Loading browser source in OBS.');
toast.className = 'on ok';
toast.innerHTML = `<strong>manifest saved</strong><pre>${escapeHtml(lines.join('\n'))}</pre>`;
} catch (err) {
toast.className = 'on err';
toast.innerHTML = `✗ network error<pre>${escapeHtml(err.message)}</pre>`;
} finally {
submitBtn.disabled = false;
spinner.textContent = '';
}
});
// ─── boot ──────────────────────────────────────────────────────────────
renderPreview();
pushRow.classList.toggle('on', pushInput.checked);
</script>
</body>
</html>