|
|
|
@@ -1,6 +1,11 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { onMount } from 'svelte';
|
|
|
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
|
|
|
import { sessionResponseState, sessionRetryDelay } from './session-recovery.js';
|
|
|
|
|
|
|
|
|
|
let sessionRestoreTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
|
let sessionRestoreAttempt = 0;
|
|
|
|
|
let sessionRestoreGeneration = 0;
|
|
|
|
|
|
|
|
|
|
type ClientSettings = { server_address: string; username: string };
|
|
|
|
|
|
|
|
|
@@ -8,9 +13,23 @@
|
|
|
|
|
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validatedServerAddress(value: string): string {
|
|
|
|
|
const candidate = value.trim();
|
|
|
|
|
let parsed: URL;
|
|
|
|
|
try {
|
|
|
|
|
parsed = new URL(candidate);
|
|
|
|
|
} catch {
|
|
|
|
|
throw new Error('The server address must be a valid HTTP or HTTPS URL.');
|
|
|
|
|
}
|
|
|
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || !parsed.hostname) {
|
|
|
|
|
throw new Error('The server address must use HTTP or HTTPS without credentials or URL data.');
|
|
|
|
|
}
|
|
|
|
|
return parsed.toString().replace(/\/$/, '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveNativeSession() {
|
|
|
|
|
if (!nativeClientAvailable() || !username || !serverAddress || !sessionToken) return;
|
|
|
|
|
const settings: ClientSettings = { server_address: serverAddress, username };
|
|
|
|
|
const settings: ClientSettings = { server_address: validatedServerAddress(serverAddress), username };
|
|
|
|
|
await invoke('save_client_settings', { settings });
|
|
|
|
|
await invoke('store_server_session', { settings, session: sessionToken });
|
|
|
|
|
}
|
|
|
|
@@ -24,21 +43,92 @@
|
|
|
|
|
if (!nativeClientAvailable()) return;
|
|
|
|
|
try {
|
|
|
|
|
const settings = await invoke<ClientSettings>('load_client_settings');
|
|
|
|
|
serverAddress = settings.server_address;
|
|
|
|
|
serverAddress = validatedServerAddress(settings.server_address);
|
|
|
|
|
username = settings.username;
|
|
|
|
|
} catch {
|
|
|
|
|
authError = 'Native settings could not be read. Enter the server address and username again.';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadAuthenticatedData() {
|
|
|
|
|
if (mustReplaceCredentials) return;
|
|
|
|
|
await loadActivity();
|
|
|
|
|
await loadNotifications();
|
|
|
|
|
await loadDiagnostics();
|
|
|
|
|
await loadOAuthStatus();
|
|
|
|
|
await loadCollaboration();
|
|
|
|
|
await loadCollaborators();
|
|
|
|
|
await loadBotCommands();
|
|
|
|
|
await loadBotSecrets();
|
|
|
|
|
await loadPlugins();
|
|
|
|
|
await loadOverlays();
|
|
|
|
|
await loadBackups();
|
|
|
|
|
await loadStreamInfo();
|
|
|
|
|
connectRealtime();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function restoreNativeSession() {
|
|
|
|
|
if (!nativeClientAvailable() || !username || !serverAddress) return;
|
|
|
|
|
const generation = ++sessionRestoreGeneration;
|
|
|
|
|
clearTimeout(sessionRestoreTimer);
|
|
|
|
|
let address = serverAddress;
|
|
|
|
|
const account = username;
|
|
|
|
|
const isCurrentRestore = () => generation === sessionRestoreGeneration && address === serverAddress && account === username;
|
|
|
|
|
try {
|
|
|
|
|
const safeServerAddress = validatedServerAddress(serverAddress);
|
|
|
|
|
serverAddress = safeServerAddress;
|
|
|
|
|
address = safeServerAddress;
|
|
|
|
|
const settings: ClientSettings = { server_address: safeServerAddress, username };
|
|
|
|
|
const storedSession = await invoke<string | null>('load_server_session', { settings });
|
|
|
|
|
if (!storedSession || !isCurrentRestore()) return;
|
|
|
|
|
const response = await fetch(`${safeServerAddress}/api/v1/auth/session`, {
|
|
|
|
|
headers: { authorization: `Bearer ${storedSession}` },
|
|
|
|
|
});
|
|
|
|
|
if (!isCurrentRestore()) return;
|
|
|
|
|
const state = sessionResponseState(response.status);
|
|
|
|
|
if (state === 'revoked') {
|
|
|
|
|
await invoke('clear_server_session', { settings });
|
|
|
|
|
if (!isCurrentRestore()) return;
|
|
|
|
|
sessionToken = '';
|
|
|
|
|
authenticated = false;
|
|
|
|
|
authError = 'Authentication revoked. Log in again.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (state !== 'valid') throw new Error('Session verification unavailable');
|
|
|
|
|
const result: { must_replace_credentials: boolean } = await response.json();
|
|
|
|
|
if (!isCurrentRestore()) return;
|
|
|
|
|
sessionRestoreAttempt = 0;
|
|
|
|
|
authError = '';
|
|
|
|
|
sessionToken = storedSession;
|
|
|
|
|
authenticated = true;
|
|
|
|
|
mustReplaceCredentials = result.must_replace_credentials !== false;
|
|
|
|
|
connectionState = 'CONNECTED';
|
|
|
|
|
await loadAuthenticatedData().catch(() => {
|
|
|
|
|
authError = 'Authenticated, but dashboard data could not be loaded.';
|
|
|
|
|
});
|
|
|
|
|
if (mustReplaceCredentials) newUsername = username;
|
|
|
|
|
} catch {
|
|
|
|
|
if (!isCurrentRestore()) return;
|
|
|
|
|
// Transport/server failures are not proof of revocation: preserve the keyring.
|
|
|
|
|
authError = 'Server unavailable. Retrying saved session.';
|
|
|
|
|
sessionRestoreTimer = setTimeout(() => {
|
|
|
|
|
if (isCurrentRestore()) void restoreNativeSession();
|
|
|
|
|
}, sessionRetryDelay(sessionRestoreAttempt++));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let serverAddress = 'http://127.0.0.1:8787';
|
|
|
|
|
let connectionState = 'OFFLINE';
|
|
|
|
|
let serverVersion = '—';
|
|
|
|
|
let serverCompatibility = '';
|
|
|
|
|
let clientUpdateStatus = '';
|
|
|
|
|
let clientUpdateUrl = '';
|
|
|
|
|
let clientUpdateBusy = false;
|
|
|
|
|
let diagnostics: { status: string; database: string; twitch_identity_count: number; unseen_notification_count: number; realtime: string } | undefined;
|
|
|
|
|
let backups: Array<{ filename: string; size_bytes: number; modified_at: string | null }> = [];
|
|
|
|
|
let backupError = '';
|
|
|
|
|
let backupBusy = false;
|
|
|
|
|
let restoreBusy = false;
|
|
|
|
|
let activeView = 'Live dashboard';
|
|
|
|
|
let sessionToken = '';
|
|
|
|
|
let authenticated = false;
|
|
|
|
@@ -53,6 +143,7 @@
|
|
|
|
|
let authBusy = false;
|
|
|
|
|
let connectionNotice = '';
|
|
|
|
|
let connectionCheckRunning = false;
|
|
|
|
|
let connectionCheckGeneration = 0;
|
|
|
|
|
let realtimeState = 'DISCONNECTED';
|
|
|
|
|
let realtimeSocket: WebSocket | undefined;
|
|
|
|
|
let activityEvents: Array<{ event_id: string; event_type: string; occurred_at: string }> = [];
|
|
|
|
@@ -90,10 +181,11 @@
|
|
|
|
|
let moderationReason = '';
|
|
|
|
|
let moderationBusy = false;
|
|
|
|
|
let moderationError = '';
|
|
|
|
|
let collaborationSession: { id: string; guests: Array<{ user_id: string; user_display_name?: string | null; slot_id?: string | null; is_live?: boolean | null }> } | null = null;
|
|
|
|
|
let collaborationSession: { id: string; guests: Array<{ user_id: string; user_display_name?: string | null; user_login?: string | null; slot_id?: string | null; is_live?: boolean | null; volume?: number | null; assigned_at?: string | null; audio_settings?: { is_host_enabled?: boolean | null; is_guest_enabled?: boolean | null; is_available?: boolean | null } | null; video_settings?: { is_host_enabled?: boolean | null; is_guest_enabled?: boolean | null; is_available?: boolean | null } | null }> } | null = null;
|
|
|
|
|
let collaborationInvites: Array<{ user_id: string; invited_at: string; status: string }> = [];
|
|
|
|
|
let collaborationBusy = false;
|
|
|
|
|
let collaborationError = '';
|
|
|
|
|
let collaborationNotice = '';
|
|
|
|
|
let showCollaboration = false;
|
|
|
|
|
let collaborationGuestId = '';
|
|
|
|
|
let collaborators: Array<{ twitch_user_id: string; display_name: string; favorite: boolean; last_invited_at: string | null }> = [];
|
|
|
|
@@ -103,11 +195,14 @@
|
|
|
|
|
let collaboratorSearchError = '';
|
|
|
|
|
let collaboratorSearchTimer: number | undefined;
|
|
|
|
|
let sharedChatSession: { session_id: string; host_broadcaster_id: string; participants: Array<{ broadcaster_id: string }> } | null = null;
|
|
|
|
|
let showSharedChatDetails = false;
|
|
|
|
|
let collaborationSettings: { is_moderator_send_live_enabled: boolean; slot_count: number; is_browser_source_audio_enabled: boolean; group_layout: string; browser_source_token: string } | null = null;
|
|
|
|
|
let collaborationCapabilities: { guest_star_available: boolean; guest_star_reason?: string | null; shared_chat_available: boolean; shared_chat_reason?: string | null } | null = null;
|
|
|
|
|
let oauthLink = '';
|
|
|
|
|
let oauthIdentity: 'streamer' | 'bot' = 'streamer';
|
|
|
|
|
let oauthBusy = false;
|
|
|
|
|
let oauthError = '';
|
|
|
|
|
let botCommands: Array<{ id: string; name: string; description: string; permission_level: string; aliases: string[]; tags: string[]; cooldown_seconds: number; cooldown_scope: string; response_template: string; error_template: string | null; enabled: boolean }> = [];
|
|
|
|
|
let botCommands: Array<{ id: string; name: string; description: string; permission_level: string; aliases: string[]; tags: string[]; cooldown_seconds: number; cooldown_scope: string; response_template: string; error_template: string | null; enabled: boolean; web_request?: { method: string; url: string; body?: string | null } | null; response_fields?: Array<[string, string]>; argument_definitions?: unknown[] }> = [];
|
|
|
|
|
let publicBotCommands: Array<{ id: string; name: string; description: string; permission_level: string; aliases: string[]; tags: string[] }> = [];
|
|
|
|
|
let botCommandError = '';
|
|
|
|
|
let commandFormId = '';
|
|
|
|
@@ -121,13 +216,32 @@
|
|
|
|
|
let commandFormResponse = '';
|
|
|
|
|
let commandFormErrorResponse = '';
|
|
|
|
|
let commandFormEnabled = true;
|
|
|
|
|
let commandWebMethod = 'GET';
|
|
|
|
|
let commandWebUrl = '';
|
|
|
|
|
let commandWebBody = '';
|
|
|
|
|
let commandResponseFields = '';
|
|
|
|
|
let commandArgumentDefinitions = '[]';
|
|
|
|
|
let commandRules: Array<{ user_id: string; response_template: string | null }> = [];
|
|
|
|
|
let commandRuleUserId = '';
|
|
|
|
|
let commandRuleResponse = '';
|
|
|
|
|
let commandRuleBusy = false;
|
|
|
|
|
let commandRuleError = '';
|
|
|
|
|
let commandFormBusy = false;
|
|
|
|
|
let commandSimulationBusy = false;
|
|
|
|
|
let commandSimulationError = '';
|
|
|
|
|
let commandSimulationResult: { rendered?: string | null; extracted_variables?: Record<string, string>; error?: string | null } | null = null;
|
|
|
|
|
let commandSimulationSampleResponse = '{"status":"ok"}';
|
|
|
|
|
let commandVariableSuggestions: string[] = [];
|
|
|
|
|
let botSecrets: Array<{ id: string; name: string; description: string; used_by: string[] }> = [];
|
|
|
|
|
let secretName = '';
|
|
|
|
|
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 plugins: Array<{ id: string; plugin_id: string; name: string; version: string; manifest: { permissions: string[]; required_secrets?: string[]; api_version: string; entrypoint: string }; enabled: boolean; installed_at: string; updated_at: string }> = [];
|
|
|
|
|
let pluginError = '';
|
|
|
|
|
let pluginBusy = false;
|
|
|
|
|
let pluginDropActive = false;
|
|
|
|
|
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; alert_group_window_ms: number; alert_group_size: number; alert_display_ms: number }> = [];
|
|
|
|
|
let selectedOverlayId = '';
|
|
|
|
|
let overlayName = '';
|
|
|
|
|
let overlaySlug = '';
|
|
|
|
@@ -136,12 +250,17 @@
|
|
|
|
|
let overlayJavascript = '';
|
|
|
|
|
let overlayEnabled = true;
|
|
|
|
|
let overlayDisabledDesign = 'transparent';
|
|
|
|
|
let overlayAlertGroupWindowMs = 200;
|
|
|
|
|
let overlayAlertGroupSize = 5;
|
|
|
|
|
let overlayAlertDisplayMs = 4000;
|
|
|
|
|
let overlayError = '';
|
|
|
|
|
let overlayBusy = false;
|
|
|
|
|
let overlaySavedSignature = '';
|
|
|
|
|
let overlayAutosaveAttemptedSignature = '';
|
|
|
|
|
let pendingOverlayDraft: { overlayName: string; overlaySlug: string; overlayHtml: string; overlayCss: string; overlayJavascript: string; overlayEnabled: boolean; overlayDisabledDesign: string } | null = null;
|
|
|
|
|
let pendingOverlayDraft: { overlayName: string; overlaySlug: string; overlayHtml: string; overlayCss: string; overlayJavascript: string; overlayEnabled: boolean; overlayDisabledDesign: string; overlayAlertGroupWindowMs: number; overlayAlertGroupSize: number; overlayAlertDisplayMs: number } | null = null;
|
|
|
|
|
let overlayDiagnosticsOpen = false;
|
|
|
|
|
let overlayEditorTab: 'HTML' | 'CSS' | 'JAVASCRIPT' = 'HTML';
|
|
|
|
|
let overlayCompletion = '';
|
|
|
|
|
let overlayAutosaveTimer: number | undefined;
|
|
|
|
|
let oauthScopes: Record<'streamer' | 'bot', { connected: boolean; missing_scopes: string[]; reauthorization_required: boolean }> = {
|
|
|
|
|
streamer: { connected: false, missing_scopes: [], reauthorization_required: false },
|
|
|
|
@@ -155,6 +274,13 @@
|
|
|
|
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isVersionNewer(candidate: string, current: string) {
|
|
|
|
|
const candidateParts = versionParts(candidate);
|
|
|
|
|
const currentParts = versionParts(current);
|
|
|
|
|
if (!candidateParts || !currentParts) return false;
|
|
|
|
|
return candidateParts.some((part, index) => part > currentParts[index] && candidateParts.slice(0, index).every((previous, previousIndex) => previous === currentParts[previousIndex]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isClientCompatible(minimum: string, maximum: string) {
|
|
|
|
|
const client = versionParts(CLIENT_VERSION);
|
|
|
|
|
const minimumParts = versionParts(minimum);
|
|
|
|
@@ -179,7 +305,7 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function checkServer() {
|
|
|
|
|
if (connectionCheckRunning) return;
|
|
|
|
|
const generation = ++connectionCheckGeneration;
|
|
|
|
|
connectionCheckRunning = true;
|
|
|
|
|
connectionState = 'CONNECTING';
|
|
|
|
|
let attempt = 0;
|
|
|
|
@@ -192,6 +318,7 @@
|
|
|
|
|
} else {
|
|
|
|
|
connectionNotice = 'Connecting to server';
|
|
|
|
|
}
|
|
|
|
|
if (generation !== connectionCheckGeneration) return;
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/server/info`);
|
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
|
|
@@ -217,7 +344,27 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function checkClientUpdate() {
|
|
|
|
|
clientUpdateBusy = true;
|
|
|
|
|
clientUpdateStatus = 'Checking fixed stable release source…';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('https://git.reaperofveriod.nl/api/v1/repos/ReaperOfVeriod/trixy-linux-client/releases/latest');
|
|
|
|
|
if (!response.ok) throw new Error('Release source unavailable.');
|
|
|
|
|
const release: { tag_name?: string; html_url?: string } = await response.json();
|
|
|
|
|
const latest = release.tag_name?.replace(/^v/, '') || '';
|
|
|
|
|
const current = CLIENT_VERSION.replace(/^v/, '');
|
|
|
|
|
clientUpdateUrl = release.html_url || 'https://git.reaperofveriod.nl/ReaperOfVeriod/trixy-linux-client/releases/latest';
|
|
|
|
|
clientUpdateStatus = latest && isVersionNewer(latest, current) ? `Update available: ${latest}` : `Up to date (${current})`;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
clientUpdateStatus = error instanceof Error ? error.message : 'Release source unavailable.';
|
|
|
|
|
} finally {
|
|
|
|
|
clientUpdateBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function login() {
|
|
|
|
|
sessionRestoreGeneration += 1;
|
|
|
|
|
clearTimeout(sessionRestoreTimer);
|
|
|
|
|
if (connectionState === 'INCOMPATIBLE') {
|
|
|
|
|
authError = 'This client version is not compatible with the server.';
|
|
|
|
|
return;
|
|
|
|
@@ -225,12 +372,17 @@
|
|
|
|
|
authBusy = true;
|
|
|
|
|
authError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/auth/login`, {
|
|
|
|
|
const safeServerAddress = validatedServerAddress(serverAddress);
|
|
|
|
|
serverAddress = safeServerAddress;
|
|
|
|
|
const response = await fetch(`${safeServerAddress}/api/v1/auth/login`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ username, password }),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new Error('Your username or password may be incorrect.');
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const failure = (await response.json().catch(() => undefined)) as { message?: string; code?: string } | undefined;
|
|
|
|
|
throw new Error(failure?.message || failure?.code || 'The server could not process the login.');
|
|
|
|
|
}
|
|
|
|
|
const result: { session_token: string; must_replace_credentials: boolean } = await response.json();
|
|
|
|
|
sessionToken = result.session_token;
|
|
|
|
|
authenticated = true;
|
|
|
|
@@ -239,18 +391,11 @@
|
|
|
|
|
authError = 'Login succeeded, but the session could not be stored in the Linux keyring.';
|
|
|
|
|
});
|
|
|
|
|
connectionState = 'CONNECTED';
|
|
|
|
|
await loadActivity();
|
|
|
|
|
await loadNotifications();
|
|
|
|
|
await loadDiagnostics();
|
|
|
|
|
await loadOAuthStatus();
|
|
|
|
|
await loadCollaboration();
|
|
|
|
|
await loadCollaborators();
|
|
|
|
|
await loadBotCommands();
|
|
|
|
|
await loadBotSecrets();
|
|
|
|
|
await loadOverlays();
|
|
|
|
|
await loadBackups();
|
|
|
|
|
await loadStreamInfo();
|
|
|
|
|
connectRealtime();
|
|
|
|
|
try {
|
|
|
|
|
await loadAuthenticatedData();
|
|
|
|
|
} catch {
|
|
|
|
|
authError = 'Login succeeded, but some dashboard data could not be loaded.';
|
|
|
|
|
}
|
|
|
|
|
if (!mustReplaceCredentials) password = '';
|
|
|
|
|
if (mustReplaceCredentials) newUsername = username;
|
|
|
|
|
} catch (error) {
|
|
|
|
@@ -287,6 +432,26 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function restoreBackup(filename: string) {
|
|
|
|
|
if (!window.confirm(`Restore ${filename}? This replaces the current database and cannot be undone.`)) return;
|
|
|
|
|
restoreBusy = true;
|
|
|
|
|
backupError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/admin/backups/restore`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
|
|
|
|
|
body: JSON.stringify({ filename, confirmation: 'RESTORE' }),
|
|
|
|
|
});
|
|
|
|
|
const result: { status?: string; reason?: string | null } = await response.json();
|
|
|
|
|
if (!response.ok || result.status !== 'ok') throw new Error(result.reason || 'Database restore failed.');
|
|
|
|
|
backupError = 'Database restored. Restart the server before continuing to use the dashboard.';
|
|
|
|
|
} catch (error) {
|
|
|
|
|
backupError = error instanceof Error ? error.message : 'Database restore failed.';
|
|
|
|
|
} finally {
|
|
|
|
|
restoreBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function replaceCredentials() {
|
|
|
|
|
authBusy = true;
|
|
|
|
|
authError = '';
|
|
|
|
@@ -313,6 +478,8 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function logout() {
|
|
|
|
|
sessionRestoreGeneration += 1;
|
|
|
|
|
clearTimeout(sessionRestoreTimer);
|
|
|
|
|
realtimeSocket?.close();
|
|
|
|
|
realtimeSocket = undefined;
|
|
|
|
|
realtimeState = 'DISCONNECTED';
|
|
|
|
@@ -330,15 +497,18 @@
|
|
|
|
|
chatMessages = [];
|
|
|
|
|
collaborationSession = null;
|
|
|
|
|
collaborationInvites = [];
|
|
|
|
|
collaborationSettings = null;
|
|
|
|
|
showCollaboration = false;
|
|
|
|
|
chatReplyParent = undefined;
|
|
|
|
|
chatThreadRoot = undefined;
|
|
|
|
|
pinnedMessageIds = [];
|
|
|
|
|
plugins = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function connectRealtime() {
|
|
|
|
|
realtimeSocket?.close();
|
|
|
|
|
const socketUrl = serverAddress.replace(/^http/, 'ws') + '/api/v1/ws';
|
|
|
|
|
const safeServerAddress = validatedServerAddress(serverAddress);
|
|
|
|
|
const socketUrl = safeServerAddress.replace(/^http/, 'ws') + '/api/v1/ws';
|
|
|
|
|
realtimeState = 'CONNECTING';
|
|
|
|
|
const socket = new WebSocket(socketUrl);
|
|
|
|
|
realtimeSocket = socket;
|
|
|
|
@@ -357,6 +527,9 @@
|
|
|
|
|
if (activity.event_type === 'twitch.channel.chat.message' && activity.payload?.message?.text) {
|
|
|
|
|
chatMessages = [{ event_id: activity.event_id, message_id: activity.payload.message_id ?? activity.event_id, user_id: activity.payload.chatter_user_id ?? '', user_name: activity.payload.chatter_user_name ?? 'unknown', text: activity.payload.message.text, fragments: (activity.payload.message.fragments ?? []).map((fragment) => ({ type: fragment.type, text: fragment.text, emote_id: fragment.emote?.id })), sent_at: activity.occurred_at, reply_parent_message_id: activity.payload.reply?.parent_message_id }, ...chatMessages].slice(0, 100);
|
|
|
|
|
}
|
|
|
|
|
if (activity.event_type.startsWith('twitch.channel.shared_chat.') || activity.event_type.startsWith('twitch.channel.guest_star_')) {
|
|
|
|
|
void loadCollaboration();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
realtimeState = 'DISCONNECTED';
|
|
|
|
@@ -410,6 +583,8 @@
|
|
|
|
|
|
|
|
|
|
async function loadCollaboration() {
|
|
|
|
|
try {
|
|
|
|
|
const capabilities = await fetch(`${serverAddress}/api/v1/twitch/collaboration/capabilities`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (capabilities.ok) collaborationCapabilities = await capabilities.json();
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/session`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) return;
|
|
|
|
|
collaborationSession = await response.json();
|
|
|
|
@@ -419,9 +594,14 @@
|
|
|
|
|
}
|
|
|
|
|
const sharedChat = await fetch(`${serverAddress}/api/v1/twitch/collaboration/shared-chat`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (sharedChat.ok) sharedChatSession = await sharedChat.json();
|
|
|
|
|
const settings = await fetch(`${serverAddress}/api/v1/twitch/collaboration/channel-settings`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (settings.ok) collaborationSettings = await settings.json();
|
|
|
|
|
} catch {
|
|
|
|
|
collaborationSession = null;
|
|
|
|
|
sharedChatSession = null;
|
|
|
|
|
showSharedChatDetails = false;
|
|
|
|
|
collaborationSettings = null;
|
|
|
|
|
collaborationCapabilities = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -481,6 +661,7 @@
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/session`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('Twitch could not start a Guest Star session.');
|
|
|
|
|
collaborationSession = await response.json();
|
|
|
|
|
collaborationNotice = 'Guest Star session started.';
|
|
|
|
|
await loadCollaboration();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
collaborationError = error instanceof Error ? error.message : 'Collaboration session failed.';
|
|
|
|
@@ -491,6 +672,7 @@
|
|
|
|
|
|
|
|
|
|
async function endCollaboration() {
|
|
|
|
|
if (!collaborationSession) return;
|
|
|
|
|
if (!window.confirm('End the current Guest Star collaboration? Twitch will remove the active session.')) return;
|
|
|
|
|
collaborationBusy = true;
|
|
|
|
|
collaborationError = '';
|
|
|
|
|
try {
|
|
|
|
@@ -498,6 +680,7 @@
|
|
|
|
|
if (!response.ok) throw new Error('Twitch could not end the Guest Star session.');
|
|
|
|
|
collaborationSession = null;
|
|
|
|
|
collaborationInvites = [];
|
|
|
|
|
collaborationNotice = 'Guest Star session ended.';
|
|
|
|
|
} catch (error) {
|
|
|
|
|
collaborationError = error instanceof Error ? error.message : 'Collaboration session could not end.';
|
|
|
|
|
} finally {
|
|
|
|
@@ -514,6 +697,7 @@
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/invites/send?${query}`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('Twitch could not send the collaboration invite.');
|
|
|
|
|
collaborationGuestId = '';
|
|
|
|
|
collaborationNotice = 'Collaboration invite sent.';
|
|
|
|
|
await loadCollaboration();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
collaborationError = error instanceof Error ? error.message : 'Invite could not be sent.';
|
|
|
|
@@ -524,12 +708,14 @@
|
|
|
|
|
|
|
|
|
|
async function cancelCollaborationInvite(guestId: string) {
|
|
|
|
|
if (!collaborationSession) return;
|
|
|
|
|
if (!window.confirm(`Cancel the collaboration invite for ${guestId}?`)) return;
|
|
|
|
|
collaborationBusy = true;
|
|
|
|
|
collaborationError = '';
|
|
|
|
|
try {
|
|
|
|
|
const query = `session_id=${encodeURIComponent(collaborationSession.id)}&guest_id=${encodeURIComponent(guestId)}`;
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/invites/cancel?${query}`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('Twitch could not cancel the collaboration invite.');
|
|
|
|
|
collaborationNotice = 'Collaboration invite cancelled.';
|
|
|
|
|
await loadCollaboration();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
collaborationError = error instanceof Error ? error.message : 'Invite could not be cancelled.';
|
|
|
|
@@ -538,6 +724,40 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateCollaborationSlot(action: 'assign' | 'remove', guestId: string, slotId: string) {
|
|
|
|
|
if (!collaborationSession || !slotId.trim()) return;
|
|
|
|
|
collaborationBusy = true;
|
|
|
|
|
collaborationError = '';
|
|
|
|
|
try {
|
|
|
|
|
const query = `session_id=${encodeURIComponent(collaborationSession.id)}&guest_id=${encodeURIComponent(guestId)}&slot_id=${encodeURIComponent(slotId.trim())}`;
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/slot/${action}?${query}`, { method: action === 'assign' ? 'POST' : 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error(`Twitch could not ${action} the Guest Star slot.`);
|
|
|
|
|
collaborationNotice = action === 'assign' ? 'Guest assigned to slot.' : 'Guest removed from slot.';
|
|
|
|
|
await loadCollaboration();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
collaborationError = error instanceof Error ? error.message : 'Guest Star slot update failed.';
|
|
|
|
|
} finally {
|
|
|
|
|
collaborationBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateCollaborationSlotSettings(guestId: string, slotId: string, isLive: boolean) {
|
|
|
|
|
if (!collaborationSession || !slotId.trim()) return;
|
|
|
|
|
collaborationBusy = true;
|
|
|
|
|
collaborationError = '';
|
|
|
|
|
try {
|
|
|
|
|
const query = `session_id=${encodeURIComponent(collaborationSession.id)}&guest_id=${encodeURIComponent(guestId)}&slot_id=${encodeURIComponent(slotId.trim())}&is_live=${isLive}`;
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/slot/settings?${query}`, { method: 'PATCH', headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('Twitch could not update the Guest Star slot settings.');
|
|
|
|
|
collaborationNotice = 'Guest Star slot settings updated.';
|
|
|
|
|
await loadCollaboration();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
collaborationError = error instanceof Error ? error.message : 'Guest Star slot settings update failed.';
|
|
|
|
|
} finally {
|
|
|
|
|
collaborationBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function markNotificationSeen(notificationId: string) {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/admin/notifications/${notificationId}/seen`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
@@ -755,7 +975,36 @@
|
|
|
|
|
commandFormCooldownScope = 'global';
|
|
|
|
|
commandFormResponse = '';
|
|
|
|
|
commandFormErrorResponse = '';
|
|
|
|
|
commandVariableSuggestions = [];
|
|
|
|
|
commandSimulationError = '';
|
|
|
|
|
commandSimulationResult = null;
|
|
|
|
|
commandFormEnabled = true;
|
|
|
|
|
commandWebMethod = 'GET';
|
|
|
|
|
commandWebUrl = '';
|
|
|
|
|
commandWebBody = '';
|
|
|
|
|
commandResponseFields = '';
|
|
|
|
|
commandArgumentDefinitions = '[]';
|
|
|
|
|
commandRules = [];
|
|
|
|
|
commandRuleUserId = '';
|
|
|
|
|
commandRuleResponse = '';
|
|
|
|
|
commandRuleError = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const commandVariables = ['user_id', 'user_name', 'message', 'args', ...Array.from({ length: 16 }, (_, index) => `arg_${index + 1}`)];
|
|
|
|
|
|
|
|
|
|
function updateCommandVariableSuggestions(value: string) {
|
|
|
|
|
const match = value.match(/\$\{([A-Za-z0-9_]*)$/);
|
|
|
|
|
commandVariableSuggestions = match ? commandVariables.filter((variable) => variable.startsWith(match[1])) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function insertCommandVariable(variable: string) {
|
|
|
|
|
const match = commandFormResponse.match(/\$\{[A-Za-z0-9_]*$/);
|
|
|
|
|
commandFormResponse = match ? `${commandFormResponse.slice(0, -match[0].length)}\${${variable}} ` : `${commandFormResponse}\${${variable}} `;
|
|
|
|
|
commandVariableSuggestions = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatExtractedVariables(values: Record<string, string>) {
|
|
|
|
|
return Object.entries(values).map(([key, value]) => `${key}=${value}`).join(' · ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function editCommand(command: (typeof botCommands)[number]) {
|
|
|
|
@@ -770,6 +1019,59 @@
|
|
|
|
|
commandFormResponse = command.response_template;
|
|
|
|
|
commandFormErrorResponse = command.error_template ?? '';
|
|
|
|
|
commandFormEnabled = command.enabled;
|
|
|
|
|
commandWebMethod = command.web_request?.method ?? 'GET';
|
|
|
|
|
commandWebUrl = command.web_request?.url ?? '';
|
|
|
|
|
commandWebBody = command.web_request?.body ?? '';
|
|
|
|
|
commandResponseFields = (command.response_fields ?? []).map(([name, path]) => `${name}=${path}`).join('\n');
|
|
|
|
|
commandArgumentDefinitions = JSON.stringify(command.argument_definitions ?? [], null, 2);
|
|
|
|
|
void loadCommandRules(command.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadCommandRules(commandId: string) {
|
|
|
|
|
commandRuleError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandId)}/user-rules`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('User rules could not be loaded.');
|
|
|
|
|
commandRules = await response.json();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
commandRuleError = error instanceof Error ? error.message : 'User rules could not be loaded.';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveCommandRule() {
|
|
|
|
|
if (!commandFormId || !/^\d{1,64}$/.test(commandRuleUserId)) {
|
|
|
|
|
commandRuleError = 'Enter a valid Twitch user ID.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
commandRuleBusy = true;
|
|
|
|
|
commandRuleError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandFormId)}/user-rules`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ user_id: commandRuleUserId, response_template: commandRuleResponse || null }) });
|
|
|
|
|
const result = await response.json().catch(() => ({}));
|
|
|
|
|
if (!response.ok) throw new Error(result.error || 'The user rule could not be saved.');
|
|
|
|
|
commandRuleUserId = '';
|
|
|
|
|
commandRuleResponse = '';
|
|
|
|
|
await loadCommandRules(commandFormId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
commandRuleError = error instanceof Error ? error.message : 'The user rule could not be saved.';
|
|
|
|
|
} finally {
|
|
|
|
|
commandRuleBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteCommandRule(userId: string) {
|
|
|
|
|
if (!commandFormId) return;
|
|
|
|
|
commandRuleBusy = true;
|
|
|
|
|
commandRuleError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandFormId)}/user-rules/${encodeURIComponent(userId)}`, { method: 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('The user rule could not be deleted.');
|
|
|
|
|
await loadCommandRules(commandFormId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
commandRuleError = error instanceof Error ? error.message : 'The user rule could not be deleted.';
|
|
|
|
|
} finally {
|
|
|
|
|
commandRuleBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveCommand() {
|
|
|
|
@@ -777,11 +1079,21 @@
|
|
|
|
|
botCommandError = '';
|
|
|
|
|
const aliases = commandFormAliases.split(',').map((value) => value.trim()).filter(Boolean);
|
|
|
|
|
const tags = commandFormTags.split(',').map((value) => value.trim()).filter(Boolean);
|
|
|
|
|
const responseFields = commandResponseFields.split('\n').map((value) => value.trim()).filter(Boolean).map((value) => value.split('=').map((part) => part.trim())).filter((parts): parts is [string, string] => parts.length === 2 && Boolean(parts[0]) && Boolean(parts[1]));
|
|
|
|
|
let argumentDefinitions: unknown[] = [];
|
|
|
|
|
try {
|
|
|
|
|
argumentDefinitions = JSON.parse(commandArgumentDefinitions);
|
|
|
|
|
if (!Array.isArray(argumentDefinitions)) throw new Error('Argument definitions must be a JSON array.');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
botCommandError = error instanceof Error ? error.message : 'Argument definitions must be valid JSON.';
|
|
|
|
|
commandFormBusy = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(commandFormId ? `${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandFormId)}` : `${serverAddress}/api/v1/bot/commands`, {
|
|
|
|
|
method: commandFormId ? 'PATCH' : 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
|
|
|
|
|
body: JSON.stringify({ name: commandFormName, description: commandFormDescription, permission_level: commandFormPermission, aliases, tags, cooldown_seconds: Number(commandFormCooldown), cooldown_scope: commandFormCooldownScope, response_template: commandFormResponse, error_template: commandFormErrorResponse || null, enabled: commandFormEnabled }),
|
|
|
|
|
body: JSON.stringify({ name: commandFormName, description: commandFormDescription, permission_level: commandFormPermission, aliases, tags, cooldown_seconds: Number(commandFormCooldown), cooldown_scope: commandFormCooldownScope, response_template: commandFormResponse, error_template: commandFormErrorResponse || null, enabled: commandFormEnabled, web_request: commandWebUrl ? { method: commandWebMethod, url: commandWebUrl, body: commandWebBody || null } : null, response_fields: responseFields, argument_definitions: argumentDefinitions }),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) throw new Error('The command could not be saved. Check its name, aliases, and fields.');
|
|
|
|
|
resetCommandForm();
|
|
|
|
@@ -794,6 +1106,42 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function simulateCommand(useRealRequest = false) {
|
|
|
|
|
commandSimulationBusy = true;
|
|
|
|
|
commandSimulationError = '';
|
|
|
|
|
commandSimulationResult = null;
|
|
|
|
|
let sampleResponse: unknown = undefined;
|
|
|
|
|
if (!useRealRequest) {
|
|
|
|
|
try {
|
|
|
|
|
sampleResponse = JSON.parse(commandSimulationSampleResponse);
|
|
|
|
|
} catch {
|
|
|
|
|
commandSimulationBusy = false;
|
|
|
|
|
commandSimulationError = 'Sample response must contain valid JSON.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/bot/commands/simulate`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
template: commandFormResponse,
|
|
|
|
|
variables: { user_name: 'PreviewUser', user_id: 'preview', message: '!preview', args: 'sample' },
|
|
|
|
|
sample_response: sampleResponse,
|
|
|
|
|
use_real_request: useRealRequest,
|
|
|
|
|
response_fields: [],
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
const result = await response.json().catch(() => ({}));
|
|
|
|
|
if (!response.ok) throw new Error(result.error || 'The command simulation failed.');
|
|
|
|
|
commandSimulationResult = result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
commandSimulationError = error instanceof Error ? error.message : 'The command simulation failed.';
|
|
|
|
|
} finally {
|
|
|
|
|
commandSimulationBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteCommand(id: string) {
|
|
|
|
|
if (!window.confirm('Delete this command permanently?')) return;
|
|
|
|
|
botCommandError = '';
|
|
|
|
@@ -853,6 +1201,126 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadPlugins() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('Plugins could not be loaded.');
|
|
|
|
|
plugins = await response.json();
|
|
|
|
|
pluginError = '';
|
|
|
|
|
} catch (error) {
|
|
|
|
|
pluginError = error instanceof Error ? error.message : 'Plugins could not be loaded.';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function installPluginFile(file: File) {
|
|
|
|
|
if (file.size > 6 * 1024 * 1024) {
|
|
|
|
|
pluginError = 'Plugin package is too large (maximum 6 MiB).';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pluginBusy = true;
|
|
|
|
|
pluginError = '';
|
|
|
|
|
try {
|
|
|
|
|
const packageText = await file.text();
|
|
|
|
|
const packageValue = JSON.parse(packageText) as { manifest?: unknown; wasm_base64?: unknown };
|
|
|
|
|
if (!packageValue.manifest || typeof packageValue.wasm_base64 !== 'string') throw new Error('Package must contain manifest and wasm_base64 fields.');
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify(packageValue) });
|
|
|
|
|
const result = (await response.json().catch(() => ({}))) as { code?: string; reason?: string };
|
|
|
|
|
if (!response.ok) throw new Error(result.reason || result.code || 'Plugin installation failed.');
|
|
|
|
|
await loadPlugins();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
pluginError = error instanceof Error ? error.message : 'Plugin installation failed.';
|
|
|
|
|
} finally {
|
|
|
|
|
pluginBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handlePluginFileInput(event: Event) {
|
|
|
|
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
|
|
|
|
if (file) void installPluginFile(file);
|
|
|
|
|
(event.currentTarget as HTMLInputElement).value = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handlePluginDrop(event: DragEvent) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
pluginDropActive = false;
|
|
|
|
|
const file = event.dataTransfer?.files?.[0];
|
|
|
|
|
if (file) void installPluginFile(file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function setPluginEnabled(plugin: (typeof plugins)[number]) {
|
|
|
|
|
pluginBusy = true;
|
|
|
|
|
pluginError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}`, { method: 'PATCH', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ enabled: !plugin.enabled }) });
|
|
|
|
|
if (!response.ok) throw new Error('Plugin state could not be changed.');
|
|
|
|
|
await loadPlugins();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
pluginError = error instanceof Error ? error.message : 'Plugin state could not be changed.';
|
|
|
|
|
} finally {
|
|
|
|
|
pluginBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function bindPluginSecret(plugin: (typeof plugins)[number]) {
|
|
|
|
|
const secretName = window.prompt('Enter the existing secret name to approve for this plugin:');
|
|
|
|
|
if (!secretName) return;
|
|
|
|
|
pluginBusy = true;
|
|
|
|
|
pluginError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}/secrets`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ secret_name: secretName, approved: true }) });
|
|
|
|
|
if (!response.ok) throw new Error('The plugin secret binding could not be approved.');
|
|
|
|
|
pluginError = `Approved ${secretName} for ${plugin.name}. Enable the plugin when all requirements are satisfied.`;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
pluginError = error instanceof Error ? error.message : 'The plugin secret binding could not be approved.';
|
|
|
|
|
} finally {
|
|
|
|
|
pluginBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runPlugin(plugin: (typeof plugins)[number]) {
|
|
|
|
|
pluginBusy = true;
|
|
|
|
|
pluginError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}/run`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('The plugin execution failed or the plugin is disabled.');
|
|
|
|
|
pluginError = `${plugin.name} executed successfully.`;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
pluginError = error instanceof Error ? error.message : 'The plugin execution failed.';
|
|
|
|
|
} finally {
|
|
|
|
|
pluginBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function removePlugin(plugin: (typeof plugins)[number]) {
|
|
|
|
|
if (!window.confirm(`Remove plugin ${plugin.name}?`)) return;
|
|
|
|
|
pluginBusy = true;
|
|
|
|
|
pluginError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}`, { method: 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('Plugin could not be removed.');
|
|
|
|
|
await loadPlugins();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
pluginError = error instanceof Error ? error.message : 'Plugin could not be removed.';
|
|
|
|
|
} finally {
|
|
|
|
|
pluginBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function rollbackPlugin(plugin: (typeof plugins)[number]) {
|
|
|
|
|
if (!window.confirm(`Roll back ${plugin.name} to its previous package?`)) return;
|
|
|
|
|
pluginBusy = true;
|
|
|
|
|
pluginError = '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}/rollback`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
|
if (!response.ok) throw new Error('Plugin rollback is unavailable for this installation.');
|
|
|
|
|
await loadPlugins();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
pluginError = error instanceof Error ? error.message : 'Plugin rollback failed.';
|
|
|
|
|
} finally {
|
|
|
|
|
pluginBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadOverlays() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${serverAddress}/api/v1/overlays`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
|
|
|
@@ -875,13 +1343,16 @@
|
|
|
|
|
overlayJavascript = overlay.draft_javascript;
|
|
|
|
|
overlayEnabled = overlay.enabled;
|
|
|
|
|
overlayDisabledDesign = overlay.disabled_design;
|
|
|
|
|
overlayAlertGroupWindowMs = overlay.alert_group_window_ms ?? 200;
|
|
|
|
|
overlayAlertGroupSize = overlay.alert_group_size ?? 5;
|
|
|
|
|
overlayAlertDisplayMs = overlay.alert_display_ms ?? 4000;
|
|
|
|
|
overlaySavedSignature = overlayDraftSignature();
|
|
|
|
|
overlayAutosaveAttemptedSignature = overlaySavedSignature;
|
|
|
|
|
restoreOverlayDraft(overlay.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function overlayDraftSignature() {
|
|
|
|
|
return JSON.stringify({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign });
|
|
|
|
|
return JSON.stringify({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign, overlayAlertGroupWindowMs, overlayAlertGroupSize, overlayAlertDisplayMs });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function overlayVariableWarnings() {
|
|
|
|
@@ -899,6 +1370,24 @@
|
|
|
|
|
return warnings;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function overlayLineNumbers(source: string) {
|
|
|
|
|
return Array.from({ length: Math.max(1, source.split('\n').length) }, (_, index) => index + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function overlayCompletionOptions() {
|
|
|
|
|
if (overlayEditorTab === 'HTML') return ['<div class="alert">${text}</div>', '<span>${text}</span>'];
|
|
|
|
|
if (overlayEditorTab === 'CSS') return ['.alert { color: var(--ed-cyan); }', 'display: flex; align-items: center;'];
|
|
|
|
|
return ["document.querySelector('.alert')", "window.addEventListener('trixie-alert', (event) => {})"];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function insertOverlayCompletion() {
|
|
|
|
|
if (!overlayCompletion) return;
|
|
|
|
|
if (overlayEditorTab === 'HTML') overlayHtml += overlayCompletion;
|
|
|
|
|
if (overlayEditorTab === 'CSS') overlayCss += overlayCompletion;
|
|
|
|
|
if (overlayEditorTab === 'JAVASCRIPT') overlayJavascript += overlayCompletion;
|
|
|
|
|
overlayCompletion = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function overlayDraftCacheKey(id: string) {
|
|
|
|
|
return `trixie.overlay.draft.${id}`;
|
|
|
|
|
}
|
|
|
|
@@ -906,7 +1395,7 @@
|
|
|
|
|
function cacheOverlayDraft() {
|
|
|
|
|
if (!selectedOverlayId) return;
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(overlayDraftCacheKey(selectedOverlayId), JSON.stringify({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign }));
|
|
|
|
|
localStorage.setItem(overlayDraftCacheKey(selectedOverlayId), JSON.stringify({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign, overlayAlertGroupWindowMs, overlayAlertGroupSize, overlayAlertDisplayMs }));
|
|
|
|
|
} catch {
|
|
|
|
|
// Local storage is an optional recovery aid; server persistence remains authoritative.
|
|
|
|
|
}
|
|
|
|
@@ -914,7 +1403,7 @@
|
|
|
|
|
|
|
|
|
|
function restoreOverlayDraft(id: string) {
|
|
|
|
|
try {
|
|
|
|
|
const cached = JSON.parse(localStorage.getItem(overlayDraftCacheKey(id)) || 'null') as Partial<{ overlayName: string; overlaySlug: string; overlayHtml: string; overlayCss: string; overlayJavascript: string; overlayEnabled: boolean; overlayDisabledDesign: string }> | null;
|
|
|
|
|
const cached = JSON.parse(localStorage.getItem(overlayDraftCacheKey(id)) || 'null') as Partial<{ overlayName: string; overlaySlug: string; overlayHtml: string; overlayCss: string; overlayJavascript: string; overlayEnabled: boolean; overlayDisabledDesign: string; overlayAlertGroupWindowMs: number; overlayAlertGroupSize: number; overlayAlertDisplayMs: number }> | null;
|
|
|
|
|
if (!cached || typeof cached.overlayName !== 'string') return;
|
|
|
|
|
const cachedSignature = JSON.stringify(cached);
|
|
|
|
|
if (cachedSignature !== overlayDraftSignature()) pendingOverlayDraft = {
|
|
|
|
@@ -925,6 +1414,9 @@
|
|
|
|
|
overlayJavascript: cached.overlayJavascript ?? overlayJavascript,
|
|
|
|
|
overlayEnabled: cached.overlayEnabled ?? overlayEnabled,
|
|
|
|
|
overlayDisabledDesign: cached.overlayDisabledDesign ?? overlayDisabledDesign,
|
|
|
|
|
overlayAlertGroupWindowMs: cached.overlayAlertGroupWindowMs ?? overlayAlertGroupWindowMs,
|
|
|
|
|
overlayAlertGroupSize: cached.overlayAlertGroupSize ?? overlayAlertGroupSize,
|
|
|
|
|
overlayAlertDisplayMs: cached.overlayAlertDisplayMs ?? overlayAlertDisplayMs,
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore malformed or unavailable local cache entries.
|
|
|
|
@@ -940,16 +1432,19 @@
|
|
|
|
|
selectedOverlayId = '';
|
|
|
|
|
overlayName = 'New Overlay';
|
|
|
|
|
overlaySlug = `overlay-${Date.now()}`;
|
|
|
|
|
overlayHtml = '<div class="alert">Hello ${username}</div>';
|
|
|
|
|
overlayCss = '.alert { color: white; font: 32px sans-serif; }';
|
|
|
|
|
overlayJavascript = '';
|
|
|
|
|
overlayHtml = '<div id="trixie-follow-alert" class="alert" role="status" aria-live="polite"><span id="trixie-follow-name"></span> followed!</div>';
|
|
|
|
|
overlayCss = '.alert { opacity: 0; transform: translateY(12px); transition: opacity 180ms ease, transform 180ms ease; color: white; font: 700 32px sans-serif; padding: 18px 24px; border-radius: 8px; background: rgba(25, 18, 40, 0.92); } .alert.visible { opacity: 1; transform: translateY(0); }';
|
|
|
|
|
overlayJavascript = "const alertBox = document.getElementById('trixie-follow-alert'); const alertName = document.getElementById('trixie-follow-name'); window.addEventListener('trixie-alert', (event) => { const detail = event.detail || {}; const payload = detail.payload || {}; if (detail.event_type !== 'follow') return; alertName.textContent = String(payload.user_name || payload.user_login || 'Someone'); alertBox.classList.add('visible'); clearTimeout(window.trixieAlertTimer); window.trixieAlertTimer = setTimeout(() => alertBox.classList.remove('visible'), 6000); });";
|
|
|
|
|
overlayEnabled = true;
|
|
|
|
|
overlayDisabledDesign = 'transparent';
|
|
|
|
|
overlayAlertGroupWindowMs = 200;
|
|
|
|
|
overlayAlertGroupSize = 5;
|
|
|
|
|
overlayAlertDisplayMs = 4000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restorePendingOverlayDraft() {
|
|
|
|
|
if (!pendingOverlayDraft) return;
|
|
|
|
|
({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign } = pendingOverlayDraft);
|
|
|
|
|
({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign, overlayAlertGroupWindowMs, overlayAlertGroupSize, overlayAlertDisplayMs } = pendingOverlayDraft);
|
|
|
|
|
pendingOverlayDraft = null;
|
|
|
|
|
overlaySavedSignature = '';
|
|
|
|
|
overlayAutosaveAttemptedSignature = '';
|
|
|
|
@@ -965,7 +1460,7 @@
|
|
|
|
|
overlayBusy = true;
|
|
|
|
|
overlayError = '';
|
|
|
|
|
try {
|
|
|
|
|
const body = JSON.stringify({ name: overlayName, slug: overlaySlug, html: overlayHtml, css: overlayCss, javascript: overlayJavascript, disabled_design: overlayDisabledDesign, enabled: overlayEnabled });
|
|
|
|
|
const body = JSON.stringify({ name: overlayName, slug: overlaySlug, html: overlayHtml, css: overlayCss, javascript: overlayJavascript, disabled_design: overlayDisabledDesign, enabled: overlayEnabled, alert_group_window_ms: Number(overlayAlertGroupWindowMs), alert_group_size: Number(overlayAlertGroupSize), alert_display_ms: Number(overlayAlertDisplayMs) });
|
|
|
|
|
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();
|
|
|
|
@@ -1065,6 +1560,7 @@
|
|
|
|
|
await loadNativeSettings();
|
|
|
|
|
void checkServer();
|
|
|
|
|
void loadPublicCommands();
|
|
|
|
|
void restoreNativeSession();
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
@@ -1074,7 +1570,7 @@
|
|
|
|
|
<header class="app-bar">
|
|
|
|
|
<div class="brand"><span class="brand-mark">◇</span><span>TRIXIE</span></div>
|
|
|
|
|
<nav class="top-nav" aria-label="Primary navigation">
|
|
|
|
|
{#each ['Live dashboard', 'Chat', 'Bot', 'Overlays', 'Settings'] as item}
|
|
|
|
|
{#each ['Live dashboard', 'Chat', 'Bot', 'Plugins', 'Collaboration', 'Overlays', 'Settings'] as item}
|
|
|
|
|
<button class:active={activeView === item} class="nav-item" onclick={() => (activeView = item)}>{item}</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</nav>
|
|
|
|
@@ -1148,20 +1644,53 @@
|
|
|
|
|
<div class="panel-header"><span>FIRST-RUN SETUP</span></div>
|
|
|
|
|
<div class="auth-gate-body credential-form">
|
|
|
|
|
<strong>Replace the generated administrator credentials</strong>
|
|
|
|
|
<label for="setup-current-password" class="micro">CURRENT PASSWORD</label><input id="setup-current-password" class="control" type="password" bind:value={password} autocomplete="current-password" />
|
|
|
|
|
<label for="new-username" class="micro">NEW USERNAME</label><input id="new-username" class="control" bind:value={newUsername} autocomplete="username" />
|
|
|
|
|
<label for="new-password" class="micro">NEW PASSWORD</label><input id="new-password" class="control" type="password" bind:value={newPassword} autocomplete="new-password" />
|
|
|
|
|
<button class="primary" onclick={replaceCredentials} disabled={authBusy}>SAVE AND CONTINUE</button>
|
|
|
|
|
{#if authError}<div class="auth-error">{authError}</div>{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
{:else if activeView === 'Plugins'}
|
|
|
|
|
<section class="plugins-page panel"><div class="panel-header"><span>WEBASSEMBLY PLUGINS</span><button class="secondary" onclick={loadPlugins}>REFRESH</button></div><div class="settings-body"><p class="settings-note">Packages are validated and started in a resource-limited WebAssembly runtime. Only explicitly declared Trixie permissions are accepted; installation never grants filesystem or process access.</p><label class:drop-active={pluginDropActive} class="plugin-drop-zone" ondragover={(event) => { event.preventDefault(); pluginDropActive = true; }} ondragleave={() => (pluginDropActive = false)} ondrop={handlePluginDrop}><strong>DROP PLUGIN PACKAGE HERE</strong><span>or choose a `.trixie-plugin` JSON package</span><input class="visually-hidden" type="file" accept=".trixie-plugin,.json,application/json" onchange={handlePluginFileInput} /></label><div class="overlay-controls"><label class="primary plugin-file-button">CHOOSE PACKAGE<input class="visually-hidden" type="file" accept=".trixie-plugin,.json,application/json" onchange={handlePluginFileInput} /></label></div>{#if pluginError}<div class="auth-error">{pluginError}</div>{/if}{#if plugins.length}<div class="plugin-list">{#each plugins as plugin}<article class="plugin-row"><div><strong>{plugin.name}</strong><span class="bot-command-description">{plugin.plugin_id} · v{plugin.version}</span><div class="micro">API {plugin.manifest.api_version} · {plugin.manifest.permissions.length ? plugin.manifest.permissions.join(' · ') : 'NO PERMISSIONS'}</div></div><div class="bot-command-meta"><span class:good={plugin.enabled}>{plugin.enabled ? 'ENABLED' : 'DISABLED'}</span><button class="secondary-action" onclick={() => setPluginEnabled(plugin)} disabled={pluginBusy}>{plugin.enabled ? 'DISABLE' : 'ENABLE'}</button><button class="secondary-action" onclick={() => rollbackPlugin(plugin)} disabled={pluginBusy}>ROLLBACK</button><button class="secondary-action danger" onclick={() => removePlugin(plugin)} disabled={pluginBusy}>REMOVE</button></div></article>{/each}</div>{:else}<div class="empty-row">No plugins are installed.</div>{/if}</div></section>
|
|
|
|
|
<section class="panel plugin-secrets-panel"><div class="panel-header"><span>PLUGIN SECRET APPROVAL</span></div><div class="settings-body"><p class="settings-note">Plugins cannot read secrets until an administrator explicitly approves a binding. The secret value is never shown here.</p>{#if plugins.some((plugin) => plugin.manifest.required_secrets?.length)}<div class="bot-command-list">{#each plugins.filter((plugin) => plugin.manifest.required_secrets?.length) as plugin}<article class="bot-command-row"><div><strong>{plugin.name}</strong><span class="bot-command-description">Required: {plugin.manifest.required_secrets?.join(' · ')}</span></div><button class="secondary-action" onclick={() => bindPluginSecret(plugin)} disabled={pluginBusy}>APPROVE SECRET</button></article>{/each}</div>{:else}<div class="empty-row">Installed plugins have no secret requirements.</div>{/if}</div></section>
|
|
|
|
|
<section class="panel plugin-runtime-panel"><div class="panel-header"><span>PLUGIN RUNTIME</span></div><div class="settings-body"><p class="settings-note">Run only enabled packages. The server applies the manifest permissions and WebAssembly resource limits.</p>{#if plugins.length}{#each plugins as plugin}<div class="bot-command-row"><div><strong>{plugin.name}</strong><span class="bot-command-description">{plugin.enabled ? 'READY' : 'DISABLED'}</span></div><button class="primary" onclick={() => runPlugin(plugin)} disabled={pluginBusy || !plugin.enabled}>RUN</button></div>{/each}{:else}<div class="empty-row">No installed plugins.</div>{/if}</div></section>
|
|
|
|
|
{:else if activeView === 'Bot'}
|
|
|
|
|
<section class="bot-command-editor panel"><div class="panel-header"><span>{commandFormId ? 'EDIT COMMAND' : 'NEW COMMAND'}</span><button class="secondary" onclick={resetCommandForm}>CLEAR</button></div><div class="settings-body command-form"><div class="command-form-grid"><label class="micro" for="command-name">NAME</label><input id="command-name" class="control" bind:value={commandFormName} maxlength="64" placeholder="uptime" /><label class="micro" for="command-description">DESCRIPTION</label><input id="command-description" class="control" bind:value={commandFormDescription} maxlength="500" placeholder="What this command does" /><label class="micro" for="command-permission">PERMISSION</label><select id="command-permission" class="control" bind:value={commandFormPermission}><option value="everyone">EVERYONE</option><option value="subscriber">SUBSCRIBER</option><option value="moderator">MODERATOR</option><option value="broadcaster">BROADCASTER</option></select><label class="micro" for="command-aliases">ALIASES</label><input id="command-aliases" class="control" bind:value={commandFormAliases} placeholder="alias1, alias2" /><label class="micro" for="command-tags">TAGS</label><input id="command-tags" class="control" bind:value={commandFormTags} placeholder="utility, fun" /><label class="micro" for="command-cooldown">COOLDOWN (SECONDS)</label><input id="command-cooldown" class="control" type="number" min="0" max="86400" bind:value={commandFormCooldown} /><label class="micro" for="command-cooldown-scope">COOLDOWN SCOPE</label><select id="command-cooldown-scope" class="control" bind:value={commandFormCooldownScope}><option value="global">GLOBAL</option><option value="user">PER USER</option><option value="role">PER ROLE</option></select><label class="micro" for="command-response">RESPONSE TEMPLATE</label><textarea id="command-response" class="control command-template" bind:value={commandFormResponse} maxlength="2000" placeholder="Hello ${user_name}"></textarea><label class="micro" for="command-error-response">ERROR RESPONSE (OPTIONAL)</label><textarea id="command-error-response" class="control command-template" bind:value={commandFormErrorResponse} maxlength="2000" placeholder="Something went wrong."></textarea></div><label class="overlay-enabled"><input type="checkbox" bind:checked={commandFormEnabled} /> ENABLED</label><button class="primary control-button" onclick={saveCommand} disabled={commandFormBusy || !commandFormName || !commandFormDescription || !commandFormResponse}>{commandFormBusy ? 'SAVING…' : commandFormId ? 'SAVE COMMAND' : 'CREATE COMMAND'}</button></div></section>
|
|
|
|
|
<section class="bot-command-editor panel"><div class="panel-header"><span>{commandFormId ? 'EDIT COMMAND' : 'NEW COMMAND'}</span><button class="secondary" onclick={resetCommandForm}>CLEAR</button></div><div class="settings-body command-form"><div class="command-form-grid"><label class="micro" for="command-name">NAME</label><input id="command-name" class="control" bind:value={commandFormName} maxlength="64" placeholder="uptime" /><label class="micro" for="command-description">DESCRIPTION</label><input id="command-description" class="control" bind:value={commandFormDescription} maxlength="500" placeholder="What this command does" /><label class="micro" for="command-permission">PERMISSION</label><select id="command-permission" class="control" bind:value={commandFormPermission}><option value="everyone">EVERYONE</option><option value="subscriber">SUBSCRIBER</option><option value="moderator">MODERATOR</option><option value="broadcaster">BROADCASTER</option></select><label class="micro" for="command-aliases">ALIASES</label><input id="command-aliases" class="control" bind:value={commandFormAliases} placeholder="alias1, alias2" /><label class="micro" for="command-tags">TAGS</label><input id="command-tags" class="control" bind:value={commandFormTags} placeholder="utility, fun" /><label class="micro" for="command-cooldown">COOLDOWN (SECONDS)</label><input id="command-cooldown" class="control" type="number" min="0" max="86400" bind:value={commandFormCooldown} /><label class="micro" for="command-cooldown-scope">COOLDOWN SCOPE</label><select id="command-cooldown-scope" class="control" bind:value={commandFormCooldownScope}><option value="global">GLOBAL</option><option value="user">PER USER</option><option value="role">PER ROLE</option></select><label class="micro" for="command-response">RESPONSE TEMPLATE</label><textarea id="command-response" class="control command-template" bind:value={commandFormResponse} oninput={() => updateCommandVariableSuggestions(commandFormResponse)} maxlength="2000" placeholder="Hello ${user_name}"></textarea>{#if commandVariableSuggestions.length}<div class="command-variable-picker" role="listbox" aria-label="Command variables">{#each commandVariableSuggestions as variable}<button class="secondary-action" type="button" onclick={() => insertCommandVariable(variable)}>${`{${variable}}`}</button>{/each}</div>{/if}<label class="micro" for="command-error-response">ERROR RESPONSE (OPTIONAL)</label><textarea id="command-error-response" class="control command-template" bind:value={commandFormErrorResponse} maxlength="2000" placeholder="Something went wrong."></textarea></div><label class="overlay-enabled"><input type="checkbox" bind:checked={commandFormEnabled} /> ENABLED</label><div class="command-simulation panel"><div class="micro">SIMULATION</div><p class="bot-secret-note">Preview with literal sample data. Real requests are opt-in and use the same server-side safety checks.</p><textarea class="control command-template" bind:value={commandSimulationSampleResponse} aria-label="Sample JSON response" placeholder="status ok"></textarea><div class="command-simulation-actions"><button class="secondary" type="button" onclick={() => simulateCommand(false)} disabled={commandSimulationBusy || !commandFormResponse}>SAMPLE REQUEST</button><button class="secondary" type="button" onclick={() => simulateCommand(true)} disabled={commandSimulationBusy || !commandFormResponse}>REAL REQUEST</button></div>{#if commandSimulationError}<div class="auth-error">{commandSimulationError}</div>{/if}{#if commandSimulationResult}<div class="command-simulation-result"><strong>{commandSimulationResult.rendered ?? commandSimulationResult.error ?? 'Simulation completed.'}</strong>{#if commandSimulationResult.extracted_variables && Object.keys(commandSimulationResult.extracted_variables).length}<span>Extracted: {formatExtractedVariables(commandSimulationResult.extracted_variables)}</span>{/if}</div>{/if}</div><button class="primary control-button" onclick={saveCommand} disabled={commandFormBusy || !commandFormName || !commandFormDescription || !commandFormResponse}>{commandFormBusy ? 'SAVING…' : commandFormId ? 'SAVE COMMAND' : 'CREATE COMMAND'}</button></div></section>
|
|
|
|
|
<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>
|
|
|
|
|
<section class="panel command-management-panel"><div class="panel-header"><span>COMMAND MANAGEMENT</span></div><div class="settings-body">{#each botCommands as command}<div class="bot-command-row"><div><strong>!{command.name}</strong><span class="bot-command-description">{command.enabled ? 'ENABLED' : 'DISABLED'} · {command.permission_level.toUpperCase()}</span></div><div class="bot-command-meta"><button class="secondary-action" onclick={() => editCommand(command)}>EDIT</button><button class="secondary-action danger" onclick={() => deleteCommand(command.id)}>DELETE</button></div></div>{/each}{#if !botCommands.length}<div class="empty-row">No commands are configured yet.</div>{/if}</div></section>
|
|
|
|
|
<section class="panel command-web-action-panel"><div class="panel-header"><span>WEB ACTION CONFIGURATION</span></div><div class="settings-body command-form"><p class="bot-secret-note">Optional HTTPS request and response variables. The server validates the URL and never executes template values as code.</p><div class="command-form-grid"><label class="micro" for="command-web-method">METHOD</label><select id="command-web-method" class="control" bind:value={commandWebMethod}><option value="GET">GET</option><option value="POST">POST</option></select><label class="micro" for="command-web-url">HTTPS URL</label><input id="command-web-url" class="control" bind:value={commandWebUrl} maxlength="2048" placeholder="https://example.com/status" /><label class="micro" for="command-web-body">JSON BODY (OPTIONAL)</label><textarea id="command-web-body" class="control command-template" bind:value={commandWebBody} maxlength="65536" placeholder="JSON body"></textarea><label class="micro" for="command-response-fields">RESPONSE VARIABLES (one name=path per line)</label><textarea id="command-response-fields" class="control command-template" bind:value={commandResponseFields} maxlength="4096" placeholder="status=$.status"></textarea></div></div></section>
|
|
|
|
|
<section class="panel command-rules-panel"><div class="panel-header"><span>USER-SPECIFIC RULES</span><span class="micro">{commandFormId ? `COMMAND ${commandFormName}` : 'SELECT A COMMAND'}</span></div><div class="settings-body command-form"><p class="bot-secret-note">A command with rules is limited to the named Twitch users. Add an optional response for a personalized variant.</p><div class="chat-send-row"><input class="control" bind:value={commandRuleUserId} inputmode="numeric" maxlength="64" placeholder="Twitch user ID" disabled={!commandFormId} /><input class="control" bind:value={commandRuleResponse} maxlength="2000" placeholder="Optional personalized response" disabled={!commandFormId} /><button class="primary" onclick={saveCommandRule} disabled={commandRuleBusy || !commandFormId}>ADD / UPDATE</button></div>{#if commandRuleError}<div class="auth-error">{commandRuleError}</div>{/if}{#if commandRules.length}<div class="bot-command-list">{#each commandRules as rule}<div class="bot-command-row"><div><strong>{rule.user_id}</strong><span class="bot-command-description">{rule.response_template || 'Uses the command response template'}</span></div><button class="secondary-action danger" onclick={() => deleteCommandRule(rule.user_id)} disabled={commandRuleBusy}>DELETE</button></div>{/each}</div>{:else}<div class="empty-row">No user-specific rules configured.</div>{/if}</div></section>
|
|
|
|
|
<section class="panel command-arguments-panel"><div class="panel-header"><span>TYPED ARGUMENTS</span><span class="micro">JSON DEFINITIONS</span></div><div class="settings-body command-form"><p class="bot-secret-note">Declare text, number, choice, username, or duration arguments. Values are validated as data and never executed.</p><textarea class="control command-template" bind:value={commandArgumentDefinitions} maxlength="12000" placeholder="[]" aria-label="Typed argument definitions"></textarea><div class="micro">Example: a JSON array containing count, number, required, minimum, and maximum fields.</div></div></section>
|
|
|
|
|
{:else if activeView === 'Collaboration'}
|
|
|
|
|
{#if collaborationSession}
|
|
|
|
|
<section class="collaboration-page panel">
|
|
|
|
|
<div class="panel-header"><span>ACTIVE COLLABORATION</span><span class="badge">{collaborationSession.guests.length} GUESTS</span></div>
|
|
|
|
|
<div class="collaboration-active-grid">
|
|
|
|
|
<div class="collaboration-call-placeholder"><div class="offline-mark">GUEST STAR CALL</div><div class="offline-subtitle">The call is controlled by Twitch</div><p class="settings-note">Twitch does not expose the call media as an embeddable Trixie API. Use Twitch for the live call controls while Trixie keeps the session, slots, invites, and shared-chat state synchronized.</p><button class="secondary" onclick={() => window.open('https://www.twitch.tv', '_blank', 'noopener,noreferrer')}>OPEN ON TWITCH</button></div>
|
|
|
|
|
<div class="collaboration-state-panel"><div class="micro">PARTICIPANTS</div>{#each collaborationSession.guests as guest}<div class="collaboration-invite"><span>{guest.user_display_name || guest.user_id}</span><span class="micro">{guest.is_live ? 'LIVE' : 'WAITING'}{guest.slot_id ? ` · SLOT ${guest.slot_id}` : ''}</span></div>{:else}<div class="empty-row">No participants have joined.</div>{/each}<div class="micro collaboration-subheading">SHARED CHAT</div>{#if sharedChatSession}<div class="collaboration-status">ACTIVE · {sharedChatSession.participants.length} CHANNELS</div>{:else}<div class="empty-row">Shared Chat is not active.</div>{/if}{#if collaborationSettings}<div class="micro collaboration-subheading">CHANNEL SETTINGS</div><div class="collaboration-status">{collaborationSettings.slot_count} SLOTS · {collaborationSettings.group_layout}</div><div class="micro">AUDIO {collaborationSettings.is_browser_source_audio_enabled ? 'ENABLED' : 'DISABLED'} · MODERATOR LIVE CONTROL {collaborationSettings.is_moderator_send_live_enabled ? 'ENABLED' : 'DISABLED'}</div>{/if}<button class="danger control-button" onclick={endCollaboration} disabled={collaborationBusy}>END SESSION</button></div>
|
|
|
|
|
</div>
|
|
|
|
|
{#if collaborationNotice}<div class="collaboration-notice">{collaborationNotice}</div>{/if}{#if collaborationError}<div class="auth-error">{collaborationError}</div>{/if}
|
|
|
|
|
</section>
|
|
|
|
|
<section class="panel collaboration-slot-panel"><div class="panel-header"><span>PARTICIPANT CONTROLS</span><span class="micro">TWITCH-AUTHORITATIVE</span></div><div class="settings-body"><p class="settings-note">Assign, remove, or toggle live state for ready participants. Twitch remains authoritative for slot availability.</p>{#each collaborationSession.guests as guest}<div class="collaboration-invite"><span><strong>{guest.user_display_name || guest.user_id}</strong><span class="micro">{guest.slot_id ? ` SLOT ${guest.slot_id}` : ' UNSLOTTED'}</span></span><div class="chat-send-row"><input class="control" value={guest.slot_id || ''} maxlength="8" inputmode="numeric" placeholder="Slot" aria-label={`Slot for ${guest.user_display_name || guest.user_id}`} /><button class="secondary-action" onclick={(event) => { const input = event.currentTarget.parentElement?.querySelector('input') as HTMLInputElement | null; updateCollaborationSlot('assign', guest.user_id, input?.value || ''); }} disabled={collaborationBusy}>ASSIGN</button>{#if guest.slot_id}<button class="secondary-action" onclick={() => updateCollaborationSlot('remove', guest.user_id, guest.slot_id || '')} disabled={collaborationBusy}>REMOVE</button><button class="secondary-action" onclick={() => updateCollaborationSlotSettings(guest.user_id, guest.slot_id || '', !guest.is_live)} disabled={collaborationBusy}>{guest.is_live ? 'SET WAITING' : 'SET LIVE'}</button>{/if}</div></div>{/each}{#if !collaborationSession.guests.length}<div class="empty-row">No participants are ready for slot controls.</div>{/if}</div></section>
|
|
|
|
|
<section class="panel collaboration-invites-panel"><div class="panel-header"><span>INVITATIONS</span><span class="micro">TWITCH STATUS</span></div><div class="settings-body">{#if collaborationInvites.length}{#each collaborationInvites as invite}<div class="collaboration-invite"><span><strong>{invite.user_id}</strong><span class="micro">INVITED {new Date(invite.invited_at).toLocaleString()}</span></span><span class="bot-command-meta"><span>{invite.status.toUpperCase()}</span><button class="secondary-action" onclick={() => cancelCollaborationInvite(invite.user_id)} disabled={collaborationBusy || invite.status !== 'pending'}>CANCEL</button></span></div>{/each}{:else}<div class="empty-row">No invitations reported by Twitch.</div>{/if}</div></section>
|
|
|
|
|
<section class="panel collaboration-media-panel"><div class="panel-header"><span>GUEST MEDIA STATUS</span><span class="micro">TWITCH-AUTHORITATIVE</span></div><div class="settings-body">{#each collaborationSession.guests as guest}<div class="collaboration-invite"><span><strong>{guest.user_display_name || guest.user_id}</strong>{#if guest.user_login}<span class="micro"> @{guest.user_login}</span>{/if}</span><span class="micro">{guest.volume === null || guest.volume === undefined ? 'VOL —' : `VOL ${guest.volume}%`} · AUDIO {guest.audio_settings?.is_available ? 'READY' : '—'} · VIDEO {guest.video_settings?.is_available ? 'READY' : '—'}</span></div>{/each}<button class="secondary" onclick={() => window.open(streamerName ? `https://www.twitch.tv/${encodeURIComponent(streamerName)}` : 'https://www.twitch.tv', '_blank', 'noopener,noreferrer')}>OPEN STREAMER CHANNEL</button></div></section>
|
|
|
|
|
{:else}
|
|
|
|
|
<section class="collaboration-page panel">
|
|
|
|
|
<div class="panel-header"><span>COLLABORATION</span><span class="micro">SEARCH OR REVIEW INCOMING</span></div>
|
|
|
|
|
<div class="collaboration-idle-grid">
|
|
|
|
|
<div class="collaboration-search-panel"><div class="micro">FIND A COLLABORATOR</div><label class="micro" for="collaboration-search-tab">EXACT TWITCH LOGIN</label><div class="chat-send-row"><input id="collaboration-search-tab" class="control" value={collaboratorSearch} oninput={(event) => searchCollaborators(event.currentTarget.value)} autocomplete="off" placeholder="Search Twitch" /><button class="secondary" onclick={clearCollaboratorSearch} disabled={!collaboratorSearch}>CLEAR</button></div>{#if collaboratorSearchBusy}<div class="micro">SEARCHING TWITCH…</div>{/if}{#if collaboratorSearchError}<div class="auth-error">{collaboratorSearchError}<button class="secondary" onclick={() => searchCollaborators(collaboratorSearch)}>RETRY</button></div>{/if}{#if collaboratorResults.length}<div class="collaborator-results">{#each collaboratorResults as result}<div class="collaborator-result"><span><strong>{result.display_name}</strong><span class="micro"> @{result.login}</span></span><button class="primary" onclick={() => inviteCollaborator(result)} disabled={collaborationBusy}>INVITE</button></div>{/each}</div>{:else if collaboratorSearch && !collaboratorSearchBusy && !collaboratorSearchError}<div class="empty-row">No exact Twitch user found.</div>{/if}<div class="micro collaboration-subheading">FAVORITES & RECENT</div>{#if collaborators.length}<div class="collaborator-results">{#each collaborators as collaborator}<div class="collaborator-result"><span><button class="secondary" onclick={() => toggleCollaboratorFavorite(collaborator)}>{collaborator.favorite ? '★' : '☆'}</button><strong>{collaborator.display_name}</strong></span><span class="micro">{collaborator.favorite ? 'FAVORITE' : 'RECENT'}</span></div>{/each}</div>{:else}<div class="empty-row">No saved collaborators yet.</div>{/if}</div>
|
|
|
|
|
<div class="collaboration-incoming-panel"><div class="micro">INCOMING INVITATIONS</div><div class="empty-state"><div class="empty-glyph">⌁</div><strong>Waiting for a collaboration invite</strong><span>Accept or decline invitations in Twitch. Trixie will reflect the authoritative status here when Twitch reports it.</span></div></div>
|
|
|
|
|
</div>
|
|
|
|
|
{#if collaborationNotice}<div class="collaboration-notice">{collaborationNotice}</div>{/if}{#if collaborationError}<div class="auth-error">{collaborationError}</div>{/if}
|
|
|
|
|
</section>
|
|
|
|
|
{/if}
|
|
|
|
|
{: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" /><label class="overlay-enabled"><input type="checkbox" bind:checked={overlayEnabled} /> ENABLED</label><input class="control" bind:value={overlayDisabledDesign} maxlength="10000" placeholder="Disabled design" /></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>${overlayEnabled ? overlayHtml : `<div>${overlayDisabledDesign}</div>`}\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>
|
|
|
|
|
<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" /><label class="overlay-enabled"><input type="checkbox" bind:checked={overlayEnabled} /> ENABLED</label><input class="control" bind:value={overlayDisabledDesign} maxlength="10000" placeholder="Disabled design" /></div><div class="overlay-source-tabs" role="tablist" aria-label="Overlay source editors">{#each ['HTML', 'CSS', 'JAVASCRIPT'] as tab}<button class:active={overlayEditorTab === tab} class="tab" role="tab" aria-selected={overlayEditorTab === tab} onclick={() => (overlayEditorTab = tab as typeof overlayEditorTab)}>{tab}</button>{/each}</div>{#if overlayEditorTab === 'HTML'}<div class="overlay-code-wrap"><div class="overlay-line-numbers" aria-hidden="true">{#each overlayLineNumbers(overlayHtml) as line}<span>{line}</span>{/each}</div><textarea id="overlay-html" class="overlay-code" bind:value={overlayHtml} spellcheck="false" aria-label="Overlay HTML source"></textarea></div>{:else if overlayEditorTab === 'CSS'}<div class="overlay-code-wrap"><div class="overlay-line-numbers" aria-hidden="true">{#each overlayLineNumbers(overlayCss) as line}<span>{line}</span>{/each}</div><textarea id="overlay-css" class="overlay-code" bind:value={overlayCss} spellcheck="false" aria-label="Overlay CSS source"></textarea></div>{:else}<div class="overlay-code-wrap"><div class="overlay-line-numbers" aria-hidden="true">{#each overlayLineNumbers(overlayJavascript) as line}<span>{line}</span>{/each}</div><textarea id="overlay-javascript" class="overlay-code" bind:value={overlayJavascript} spellcheck="false" aria-label="Overlay JavaScript source"></textarea></div>{/if}<div class="overlay-preview-label micro">LIVE PREVIEW</div><iframe class="overlay-preview" title="Overlay preview" sandbox="allow-scripts" srcdoc={`<style>${overlayCss}</style>${overlayEnabled ? overlayHtml : `<div>${overlayDisabledDesign}</div>`}\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>
|
|
|
|
|
<section class="panel overlay-alert-settings-panel"><div class="panel-header"><span>ALERT PLAYBACK</span></div><div class="settings-body"><p class="settings-note">Configure grouping and playback for the selected browser-source overlay. Changes are saved with the next draft save.</p><div class="command-form-grid"><label class="micro" for="overlay-alert-window">GROUP WINDOW (MS)</label><input id="overlay-alert-window" class="control" type="number" min="0" max="5000" bind:value={overlayAlertGroupWindowMs} /><label class="micro" for="overlay-alert-size">MAX GROUP SIZE</label><input id="overlay-alert-size" class="control" type="number" min="1" max="20" bind:value={overlayAlertGroupSize} /><label class="micro" for="overlay-alert-display">DISPLAY TIME (MS)</label><input id="overlay-alert-display" class="control" type="number" min="500" max="60000" bind:value={overlayAlertDisplayMs} /></div></div></section>
|
|
|
|
|
<section class="panel overlay-completion-panel"><div class="panel-header"><span>EDITOR COMPLETION</span><span class="micro">{overlayEditorTab}</span></div><div class="settings-body"><p class="settings-note">Choose a literal starter snippet for the active source tab. Snippets are inserted as text and never executed by the editor.</p><div class="chat-send-row"><select class="control" bind:value={overlayCompletion}><option value="">Choose a snippet…</option>{#each overlayCompletionOptions() as completion}<option value={completion}>{completion}</option>{/each}</select><button class="secondary" onclick={insertOverlayCompletion} disabled={!overlayCompletion}>INSERT</button></div></div></section>
|
|
|
|
|
{:else if activeView === 'Settings'}
|
|
|
|
|
<section class="settings-page panel"><div class="panel-header"><span>SERVER SETTINGS</span><button class="secondary" onclick={loadBackups}>REFRESH</button></div><div class="settings-body"><div class="micro">DATABASE BACKUPS</div><p class="settings-note">Daily backups use the server's local schedule. Restore remains a destructive recovery workflow and is not started from this list.</p><button class="primary control-button" onclick={runBackup} disabled={backupBusy}>{backupBusy ? 'RUNNING…' : 'RUN BACKUP NOW'}</button>{#if backupError}<div class="auth-error">{backupError}</div>{/if}{#if backups.length}<div class="backup-list">{#each backups as backup}<div class="backup-row"><span class="mono">{backup.filename}</span><span>{Math.round(backup.size_bytes / 1024)} KiB</span><span class="micro">{backup.modified_at ? new Date(backup.modified_at).toLocaleString() : 'UNKNOWN TIME'}</span></div>{/each}</div>{:else}<div class="empty-row">No backup archives are available.</div>{/if}</div></section>
|
|
|
|
|
<section class="settings-page panel"><div class="panel-header"><span>SERVER SETTINGS</span><button class="secondary" onclick={loadBackups}>REFRESH</button></div><div class="settings-body"><div class="micro">DATABASE BACKUPS</div><p class="settings-note">Daily backups use the server's local schedule. Restore replaces the current database and is permanently destructive.</p><button class="primary control-button" onclick={runBackup} disabled={backupBusy || restoreBusy}>{backupBusy ? 'RUNNING…' : 'RUN BACKUP NOW'}</button>{#if backupError}<div class="auth-error">{backupError}</div>{/if}{#if backups.length}<div class="backup-list">{#each backups as backup}<div class="backup-row"><span class="mono">{backup.filename}</span><span>{Math.round(backup.size_bytes / 1024)} KiB</span><span class="micro">{backup.modified_at ? new Date(backup.modified_at).toLocaleString() : 'UNKNOWN TIME'}</span><button class="danger" onclick={() => restoreBackup(backup.filename)} disabled={backupBusy || restoreBusy}>RESTORE</button></div>{/each}</div>{:else}<div class="empty-row">No backup archives are available.</div>{/if}</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>
|
|
|
|
@@ -1249,5 +1778,16 @@
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if authenticated && activeView === 'Settings'}
|
|
|
|
|
<section class="panel client-update-panel"><div class="panel-header"><span>CLIENT UPDATE</span><span class="micro">v{CLIENT_VERSION}</span></div><div class="settings-body"><p class="settings-note">Updates are manual. Check the fixed stable Gitea release source, review the release, then download and replace the AppImage yourself.</p><button class="secondary" onclick={checkClientUpdate} disabled={clientUpdateBusy}>{clientUpdateBusy ? 'CHECKING…' : 'CHECK FOR UPDATE'}</button>{#if clientUpdateStatus}<span class="settings-note">{clientUpdateStatus}</span>{/if}{#if clientUpdateUrl}<button class="secondary" onclick={() => window.open(clientUpdateUrl, '_blank', 'noopener,noreferrer')}>OPEN RELEASE PAGE</button>{/if}</div></section>
|
|
|
|
|
{/if}
|
|
|
|
|
</main>
|
|
|
|
|
{#if authenticated && sharedChatSession}
|
|
|
|
|
<section class="shared-chat-status-control" aria-label="Shared Chat details">
|
|
|
|
|
<button class="secondary shared-chat-status-button" onclick={() => (showSharedChatDetails = !showSharedChatDetails)} aria-expanded={showSharedChatDetails}>◈ SHARED CHAT · {sharedChatSession.participants.length}</button>
|
|
|
|
|
{#if showSharedChatDetails}
|
|
|
|
|
<div class="shared-chat-popover panel"><div class="panel-header"><span>SHARED CHAT</span><button class="secondary" onclick={() => (showSharedChatDetails = false)}>CLOSE</button></div><div class="settings-body"><div class="micro">HOST</div><div>{sharedChatSession.host_broadcaster_id}</div><div class="micro">PARTICIPATING CHANNELS</div>{#each sharedChatSession.participants as participant}<div class="collaboration-status">{participant.broadcaster_id}</div>{/each}<p class="settings-note">Chat remains in the main chat panel. This popover only shows Twitch-authoritative collaboration details.</p></div></div>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|