add overlay editor and preview
This commit is contained in:
+100
@@ -68,6 +68,17 @@
|
||||
let secretDescription = '';
|
||||
let secretValue = '';
|
||||
let secretError = '';
|
||||
let overlays: Array<{ id: string; name: string; slug: string; draft_html: string; draft_css: string; draft_javascript: string; published: boolean; enabled: boolean; disabled_design: string }> = [];
|
||||
let selectedOverlayId = '';
|
||||
let overlayName = '';
|
||||
let overlaySlug = '';
|
||||
let overlayHtml = '';
|
||||
let overlayCss = '';
|
||||
let overlayJavascript = '';
|
||||
let overlayEnabled = true;
|
||||
let overlayDisabledDesign = 'transparent';
|
||||
let overlayError = '';
|
||||
let overlayBusy = false;
|
||||
let oauthScopes: Record<'streamer' | 'bot', { connected: boolean; missing_scopes: string[]; reauthorization_required: boolean }> = {
|
||||
streamer: { connected: false, missing_scopes: [], reauthorization_required: false },
|
||||
bot: { connected: false, missing_scopes: [], reauthorization_required: false },
|
||||
@@ -122,6 +133,7 @@
|
||||
await loadCollaboration();
|
||||
await loadBotCommands();
|
||||
await loadBotSecrets();
|
||||
await loadOverlays();
|
||||
await loadStreamInfo();
|
||||
connectRealtime();
|
||||
if (!mustReplaceCredentials) password = '';
|
||||
@@ -571,6 +583,92 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOverlays() {
|
||||
try {
|
||||
const response = await fetch(`${serverAddress}/api/v1/overlays`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
||||
if (!response.ok) throw new Error('Overlays could not be loaded.');
|
||||
overlays = await response.json();
|
||||
if (!selectedOverlayId && overlays[0]) selectOverlay(overlays[0]);
|
||||
overlayError = '';
|
||||
} catch (error) {
|
||||
overlayError = error instanceof Error ? error.message : 'Overlays could not be loaded.';
|
||||
}
|
||||
}
|
||||
|
||||
function selectOverlay(overlay: (typeof overlays)[number]) {
|
||||
selectedOverlayId = overlay.id;
|
||||
overlayName = overlay.name;
|
||||
overlaySlug = overlay.slug;
|
||||
overlayHtml = overlay.draft_html;
|
||||
overlayCss = overlay.draft_css;
|
||||
overlayJavascript = overlay.draft_javascript;
|
||||
overlayEnabled = overlay.enabled;
|
||||
overlayDisabledDesign = overlay.disabled_design;
|
||||
}
|
||||
|
||||
async function createOverlay() {
|
||||
selectedOverlayId = '';
|
||||
overlayName = 'New Overlay';
|
||||
overlaySlug = `overlay-${Date.now()}`;
|
||||
overlayHtml = '<div class="alert">Hello ${username}</div>';
|
||||
overlayCss = '.alert { color: white; font: 32px sans-serif; }';
|
||||
overlayJavascript = '';
|
||||
overlayEnabled = true;
|
||||
overlayDisabledDesign = 'transparent';
|
||||
}
|
||||
|
||||
async function saveOverlay() {
|
||||
overlayBusy = true;
|
||||
overlayError = '';
|
||||
try {
|
||||
const body = JSON.stringify({ name: overlayName, slug: overlaySlug, html: overlayHtml, css: overlayCss, javascript: overlayJavascript, disabled_design: overlayDisabledDesign });
|
||||
const response = await fetch(selectedOverlayId ? `${serverAddress}/api/v1/overlays/${selectedOverlayId}` : `${serverAddress}/api/v1/overlays`, { method: selectedOverlayId ? 'PATCH' : 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body });
|
||||
if (!response.ok) throw new Error('The overlay draft could not be saved.');
|
||||
const saved = await response.json();
|
||||
await loadOverlays();
|
||||
selectOverlay({ ...saved, enabled: overlayEnabled, disabled_design: overlayDisabledDesign });
|
||||
} catch (error) {
|
||||
overlayError = error instanceof Error ? error.message : 'The overlay draft could not be saved.';
|
||||
} finally {
|
||||
overlayBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function publishOverlay() {
|
||||
if (!selectedOverlayId) return;
|
||||
overlayBusy = true;
|
||||
overlayError = '';
|
||||
try {
|
||||
const response = await fetch(`${serverAddress}/api/v1/overlays/${selectedOverlayId}/publish`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
|
||||
if (!response.ok) throw new Error('The overlay could not be published.');
|
||||
await loadOverlays();
|
||||
} catch (error) {
|
||||
overlayError = error instanceof Error ? error.message : 'The overlay could not be published.';
|
||||
} finally {
|
||||
overlayBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function undoOverlay() {
|
||||
if (!selectedOverlayId) return;
|
||||
overlayBusy = true;
|
||||
overlayError = '';
|
||||
try {
|
||||
const response = await fetch(`${serverAddress}/api/v1/overlays/${selectedOverlayId}/undo`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
|
||||
if (!response.ok) throw new Error('There is no previous published overlay to restore.');
|
||||
await loadOverlays();
|
||||
} catch (error) {
|
||||
overlayError = error instanceof Error ? error.message : 'The previous overlay could not be restored.';
|
||||
} finally {
|
||||
overlayBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOverlayUrl() {
|
||||
if (!overlaySlug) return;
|
||||
await navigator.clipboard.writeText(`${serverAddress}/overlay/${overlaySlug}`).catch(() => (overlayError = 'Copy was unavailable; select the URL manually.'));
|
||||
}
|
||||
|
||||
async function revokeOAuth(identity: 'streamer' | 'bot') {
|
||||
oauthBusy = true;
|
||||
oauthError = '';
|
||||
@@ -685,6 +783,8 @@
|
||||
</section>
|
||||
{:else if activeView === 'Bot'}
|
||||
<section class="bot-page panel"><div class="panel-header"><span>BOT COMMANDS</span><button class="secondary" onclick={loadBotCommands}>REFRESH</button></div><div class="bot-page-body"><div class="micro">PUBLIC COMMAND CATALOG</div>{#if botCommandError}<div class="auth-error">{botCommandError}</div>{:else if botCommands.length}<div class="bot-command-list">{#each botCommands as command}<article class="bot-command-row"><div><strong>!{command.name}</strong><span class="bot-command-description">{command.description}</span>{#if command.tags.length}<div class="micro">{command.tags.join(' · ')}</div>{/if}</div><div class="bot-command-meta"><span>{command.permission_level.toUpperCase()}</span>{#if command.cooldown_seconds}<span>{command.cooldown_seconds}s / {command.cooldown_scope}</span>{/if}</div></article>{/each}</div>{:else}<div class="empty-state"><strong>No enabled commands</strong><span>Create commands on the server to make them available here.</span></div>{/if}<div class="bot-secret-section"><div class="micro">SECRETS MANAGEMENT</div><div class="bot-secret-note">Values are encrypted and cannot be read after saving.</div><div class="secret-form"><input class="control" bind:value={secretName} maxlength="100" placeholder="Name" /><input class="control" bind:value={secretDescription} maxlength="500" placeholder="Description" /><input class="control" type="password" bind:value={secretValue} maxlength="65536" placeholder="Value" /><button class="primary" onclick={createSecret} disabled={!secretName || !secretValue}>ADD SECRET</button></div>{#if botSecrets.length}<div class="bot-secret-list">{#each botSecrets as secret}<div class="bot-secret-row"><div><strong>{secret.name}</strong><span>{secret.description || 'No description'}</span>{#if secret.used_by.length}<div class="micro">USED BY: {secret.used_by.join(', ')}</div>{/if}</div><button class="secondary-action" onclick={() => deleteSecret(secret.id)}>DELETE</button></div>{/each}</div>{:else}<div class="empty-row">There are no secrets yet.</div>{/if}{#if secretError}<div class="auth-error">{secretError}</div>{/if}</div></div></section>
|
||||
{:else if activeView === 'Overlays'}
|
||||
<section class="overlay-page panel"><div class="panel-header"><span>OVERLAYS</span><div class="overlay-toolbar"><button class="secondary" onclick={createOverlay}>NEW</button><button class="secondary" onclick={loadOverlays}>REFRESH</button></div></div><div class="overlay-layout"><aside class="overlay-list">{#each overlays as overlay}<button class:active={selectedOverlayId === overlay.id} class="overlay-list-item" onclick={() => selectOverlay(overlay)}><strong>{overlay.name}</strong><span>{overlay.published ? 'PUBLISHED' : 'DRAFT'}</span></button>{/each}{#if !overlays.length}<div class="empty-row">No overlays yet.</div>{/if}</aside><div class="overlay-editor">{#if selectedOverlayId || overlayName}<div class="overlay-fields"><input class="control" bind:value={overlayName} maxlength="100" placeholder="Overlay name" /><input class="control" bind:value={overlaySlug} maxlength="100" placeholder="stable-slug" /></div><div class="overlay-tabs"><label class="micro" for="overlay-html">HTML</label><textarea id="overlay-html" class="overlay-code" bind:value={overlayHtml} spellcheck="false"></textarea><label class="micro" for="overlay-css">CSS</label><textarea id="overlay-css" class="overlay-code" bind:value={overlayCss} spellcheck="false"></textarea><label class="micro" for="overlay-javascript">JAVASCRIPT</label><textarea id="overlay-javascript" class="overlay-code" bind:value={overlayJavascript} spellcheck="false"></textarea></div><div class="overlay-preview-label micro">LIVE PREVIEW</div><iframe class="overlay-preview" title="Overlay preview" sandbox="allow-scripts" srcdoc={`<style>${overlayCss}</style>${overlayHtml}\u003cscript\u003e${overlayJavascript}\u003c/script\u003e`}></iframe><div class="overlay-controls"><button class="primary" onclick={saveOverlay} disabled={overlayBusy || !overlayName || !overlaySlug}>SAVE DRAFT</button><button class="secondary" onclick={publishOverlay} disabled={overlayBusy || !selectedOverlayId}>PUBLISH</button><button class="secondary" onclick={undoOverlay} disabled={overlayBusy || !selectedOverlayId}>UNDO PUBLISHED</button><button class="secondary" onclick={copyOverlayUrl} disabled={!overlaySlug}>COPY OBS URL</button></div>{#if overlayError}<div class="auth-error">{overlayError}</div>{/if}{:else}<div class="empty-state"><strong>Select or create an overlay</strong><span>Drafts autosave when explicitly saved; publishing controls the browser source.</span></div>{/if}</div></div></section>
|
||||
{:else}
|
||||
<div class="dashboard-grid">
|
||||
<section class="panel preview-panel"><div class="panel-header"><span>STREAM PREVIEW</span><span class="panel-menu">⋮</span></div>{#if playerSrc}<iframe class="twitch-player" title={`Twitch player for ${streamerName}`} src={playerSrc} allowfullscreen></iframe>{:else}<div class="preview-stage"><div class="offline-mark">STREAM OFFLINE</div><div class="offline-subtitle">CONNECT TWITCH TO BEGIN</div></div>{/if}<div class="panel-footer"><span class="micro">VIDEO PLAYER</span><span class="badge">{playerSrc ? streamerName : 'OFFLINE'}</span></div></section>
|
||||
|
||||
@@ -576,6 +576,66 @@ label {
|
||||
.bot-secret-row span {
|
||||
display: block;
|
||||
}
|
||||
.overlay-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
.overlay-toolbar,
|
||||
.overlay-controls {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
.overlay-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
min-height: calc(100vh - 100px);
|
||||
}
|
||||
.overlay-list {
|
||||
border-right: 1px solid var(--ui-divider);
|
||||
padding: 10px;
|
||||
}
|
||||
.overlay-list-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
color: var(--ed-dim);
|
||||
text-align: left;
|
||||
}
|
||||
.overlay-list-item.active,
|
||||
.overlay-list-item:hover {
|
||||
color: var(--ed-cyan);
|
||||
background: var(--ui-surface-hi);
|
||||
}
|
||||
.overlay-list-item span {
|
||||
font: 9px var(--font-data);
|
||||
}
|
||||
.overlay-editor {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
.overlay-fields,
|
||||
.overlay-tabs {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.overlay-code {
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
color: var(--ed-dim);
|
||||
background: var(--ui-surface-low);
|
||||
font: 12px/1.4 var(--font-data);
|
||||
}
|
||||
.overlay-preview-label {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.overlay-preview {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
border: 1px solid var(--ui-divider);
|
||||
background: #101217;
|
||||
}
|
||||
.bot-secret-row span {
|
||||
margin-top: 3px;
|
||||
color: var(--ed-dim);
|
||||
|
||||
Reference in New Issue
Block a user