Files
obs-config/webapp/resources/views/onboard.blade.php
2026-05-21 15:00:12 +02:00

965 lines
33 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="icon" type="image/x-icon" href="{{ asset('favicon.ico') }}">
<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.page-shell {
display: grid;
grid-template-rows: auto 1fr auto;
height: auto;
}
.wizard {
width: 100%;
display: grid;
grid-template-columns: 1fr 480px;
gap: 22px;
align-items: start;
}
@media (max-width: 1100px) {
.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); }
/* Olden Era extra module — appears only when the picked game is Olden Era. */
#olden-module[hidden] { display: none !important; }
#olden-module {
margin-top: 8px;
padding-top: 16px;
border-top: 1px dashed var(--term-edge);
}
#olden-module::before {
content: '┤ HEROES OF MIGHT & MAGIC: OLDEN ERA ├';
display: block;
color: var(--term-fg-bright);
font-size: 12px;
letter-spacing: 0.28em;
margin-bottom: 14px;
text-shadow: 0 0 6px var(--term-glow);
}
.olden-pick {
display: flex;
align-items: center;
gap: 12px;
}
.olden-pick select {
flex: 1 1 auto;
background: rgba(0, 30, 0, 0.45);
border: 1px solid var(--term-edge);
border-radius: 3px;
color: var(--term-fg-bright);
font: inherit;
font-size: 15px;
padding: 8px 12px;
outline: 0;
text-shadow: 0 0 6px var(--term-glow);
}
.olden-pick select:focus {
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);
}
.olden-preview {
width: 44px;
height: 44px;
object-fit: contain;
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--term-edge);
border-radius: 3px;
flex: 0 0 auto;
}
.olden-preview.olden-portrait { object-fit: cover; }
.olden-preview:not([src]),
.olden-preview[src=""] { opacity: 0.25; }
.toggle.olden-color button.olden-red.on {
background: rgba(230, 58, 46, 0.20);
color: #ff6a5e;
text-shadow: 0 0 6px rgba(230, 58, 46, 0.55);
}
.toggle.olden-color button.olden-blue.on {
background: rgba(58, 166, 255, 0.20);
color: #6ec5ff;
text-shadow: 0 0 6px rgba(58, 166, 255, 0.55);
}
/* Footer note. */
.foot-note {
text-align: center;
color: var(--term-fg-dim);
font-size: 12px;
letter-spacing: 0.18em;
}
</style>
</head>
<body class="page-shell">
@include('partials.topbar', [
'crumb' => 'ONBOARD',
'context' => '<span class="dim">RIG</span><span id="rig-name">'
. e($telemetry['rig'] ?? '—') . '</span>',
])
<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 id="olden-module" hidden>
<div class="form-row">
<label for="ol-my-faction">My Faction</label>
<div class="field olden-pick">
<select id="ol-my-faction" name="olden[my][faction]"></select>
<img class="olden-preview" id="ol-my-faction-img" alt="">
</div>
</div>
<div class="form-row">
<label for="ol-my-hero">My Main Hero</label>
<div class="field olden-pick">
<select id="ol-my-hero" name="olden[my][hero]"></select>
<img class="olden-preview olden-portrait" id="ol-my-hero-img" alt="">
</div>
</div>
<div class="form-row">
<label for="ol-enemy-faction">Enemy Faction</label>
<div class="field olden-pick">
<select id="ol-enemy-faction" name="olden[enemy][faction]"></select>
<img class="olden-preview" id="ol-enemy-faction-img" alt="">
</div>
</div>
<div class="form-row">
<label for="ol-enemy-hero">Enemy Main Hero</label>
<div class="field olden-pick">
<select id="ol-enemy-hero" name="olden[enemy][hero]"></select>
<img class="olden-preview olden-portrait" id="ol-enemy-hero-img" alt="">
</div>
</div>
<div class="form-row">
<label>My Color</label>
<div class="field">
<div class="toggle olden-color" data-toggle="oldenColor" data-value="red">
<button type="button" data-val="red" class="olden-red on">RED · LEFT</button>
<button type="button" data-val="blue" class="olden-blue">BLUE · RIGHT</button>
</div>
<input type="hidden" id="ol-color" name="oldenColor" value="red">
<div class="hint">Red your nick on the matchup's left side, in red. Blue → right side, in blue.</div>
</div>
</div>
<div class="form-row">
<label for="ol-map">Map / Template</label>
<div class="field">
<div class="prompt-line">
<span class="caret">▶</span>
<input
type="text" id="ol-map" name="olden[map]"
autocomplete="off" spellcheck="false"
placeholder="e.g. Crimson Tide">
</div>
</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' : val === 'false' ? '0' : val;
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 || '';
syncOldenVisibility();
}
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('');
}
// ─── olden era module ──────────────────────────────────────────────────
const OLDEN_GAME_ID = @json(\App\Support\OldenEra::GAME_ID);
const OLDEN_CATALOG = @json($oldenEra['catalog']);
const OLDEN_SAVED = @json($oldenEra['manifest']);
const oldenModule = $('#olden-module');
const olColorInput = $('#ol-color');
const olColorGroup = document.querySelector('.toggle.olden-color');
const olMap = $('#ol-map');
function syncOldenVisibility() {
if (!oldenModule) return;
oldenModule.hidden = gameInput.dataset.gameId !== OLDEN_GAME_ID;
}
function fillFactionSelect(sel) {
sel.innerHTML = Object.keys(OLDEN_CATALOG)
.map(f => `<option value="${escapeHtml(f)}">${escapeHtml(f)}</option>`).join('');
}
function fillHeroSelect(side, faction) {
const sel = $(`#ol-${side}-hero`);
const heroes = (OLDEN_CATALOG[faction] || {}).heroes || [];
sel.innerHTML = heroes
.map(h => `<option value="${escapeHtml(h.name)}">${escapeHtml(h.name)}</option>`).join('');
}
function updateFactionImg(side) {
const f = $(`#ol-${side}-faction`).value;
$(`#ol-${side}-faction-img`).src = (OLDEN_CATALOG[f] || {}).logo || '';
}
function updateHeroImg(side) {
const f = $(`#ol-${side}-faction`).value;
const h = $(`#ol-${side}-hero`).value;
const hero = ((OLDEN_CATALOG[f] || {}).heroes || []).find(x => x.name === h);
$(`#ol-${side}-hero-img`).src = hero ? hero.portrait : '';
}
function setColor(val) {
if (val !== 'red' && val !== 'blue') val = 'red';
olColorInput.value = val;
olColorGroup.dataset.value = val;
olColorGroup.querySelectorAll('button').forEach(b => {
b.classList.toggle('on', b.dataset.val === val);
});
}
['my', 'enemy'].forEach(side => {
const factionSel = $(`#ol-${side}-faction`);
const heroSel = $(`#ol-${side}-hero`);
fillFactionSelect(factionSel);
factionSel.addEventListener('change', () => {
fillHeroSelect(side, factionSel.value);
updateFactionImg(side);
updateHeroImg(side);
});
heroSel.addEventListener('change', () => updateHeroImg(side));
});
// Pre-populate from saved olden-era-manifest.json (if any).
(function preFillOlden() {
const saved = OLDEN_SAVED || {};
const factions = Object.keys(OLDEN_CATALOG);
if (!factions.length) return;
const pick = (want, side) => {
const f = factions.includes(want?.faction) ? want.faction : factions[0];
$(`#ol-${side}-faction`).value = f;
fillHeroSelect(side, f);
const heroes = (OLDEN_CATALOG[f] || {}).heroes || [];
const wantedHero = want?.hero && heroes.find(h => h.name === want.hero) ? want.hero
: (heroes[0] && heroes[0].name) || '';
$(`#ol-${side}-hero`).value = wantedHero;
updateFactionImg(side);
updateHeroImg(side);
};
pick(saved.my, 'my');
pick(saved.enemy, 'enemy');
setColor(saved.color || 'red');
olMap.value = saved.map || '';
})();
syncOldenVisibility();
// ─── 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,
};
if (!oldenModule.hidden) {
payload.olden = {
color: olColorInput.value || 'red',
my: { faction: $('#ol-my-faction').value, hero: $('#ol-my-hero').value },
enemy: { faction: $('#ol-enemy-faction').value, hero: $('#ol-enemy-hero').value },
map: olMap.value.trim() || null,
};
}
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.oldenPath) lines.push(`✓ wrote ${body.oldenPath}`);
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>