1792 lines
124 KiB
Svelte
1792 lines
124 KiB
Svelte
<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 };
|
||
|
||
function nativeClientAvailable() {
|
||
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: validatedServerAddress(serverAddress), username };
|
||
await invoke('save_client_settings', { settings });
|
||
await invoke('store_server_session', { settings, session: sessionToken });
|
||
}
|
||
|
||
async function clearNativeSession() {
|
||
if (!nativeClientAvailable() || !username || !serverAddress) return;
|
||
await invoke('clear_server_session', { settings: { server_address: serverAddress, username } satisfies ClientSettings });
|
||
}
|
||
|
||
async function loadNativeSettings() {
|
||
if (!nativeClientAvailable()) return;
|
||
try {
|
||
const settings = await invoke<ClientSettings>('load_client_settings');
|
||
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() {
|
||
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;
|
||
let mustReplaceCredentials = false;
|
||
let username = '';
|
||
// Keeps the command-template example literal while Svelte parses the surrounding markup.
|
||
const user_name = '${user_name}';
|
||
let password = '';
|
||
let newUsername = '';
|
||
let newPassword = '';
|
||
let authError = '';
|
||
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 }> = [];
|
||
let activityFilter: 'all' | 'moderation' = 'all';
|
||
let chatMessages: Array<{ event_id: string; message_id: string; user_id: string; user_name: string; text: string; fragments: Array<{ type: string; text: string; emote_id?: string }>; sent_at: string; reply_parent_message_id?: string }> = [];
|
||
let chatReplyParent: { message_id: string; user_name: string } | undefined;
|
||
let chatThreadRoot: { message_id: string; user_name: string; text: string } | undefined;
|
||
let pinnedMessageIds: string[] = [];
|
||
let notifications: Array<{ id: string; severity: string; title: string; message: string; seen_at: string | null; created_at: string }> = [];
|
||
let showNotifications = false;
|
||
let showStreamEditor = false;
|
||
let streamBusy = false;
|
||
let streamError = '';
|
||
let streamTitle = '';
|
||
let streamGameId = '';
|
||
let streamLanguage = 'en';
|
||
let streamTags = '';
|
||
let streamClassificationLabels = '';
|
||
let streamerName = '';
|
||
let playerSrc = '';
|
||
let categorySearch = '';
|
||
let categoryOptions: Array<{ id: string; name: string; box_art_url: string }> = [];
|
||
let categorySearchTimer: number | undefined;
|
||
let chatIdentity: 'streamer' | 'bot' = 'streamer';
|
||
let chatMessage = '';
|
||
let chatEmoteOptions: Array<{ name: string; url: string; provider: string }> = [];
|
||
let chatEmoteTimer: number | undefined;
|
||
let chatBusy = false;
|
||
let chatError = '';
|
||
let showModeration = false;
|
||
let moderationAction: 'ban' | 'timeout' | 'unban' | 'delete_message' = 'timeout';
|
||
let moderationUserId = '';
|
||
let moderationMessageId = '';
|
||
let moderationDuration = 600;
|
||
let moderationReason = '';
|
||
let moderationBusy = false;
|
||
let moderationError = '';
|
||
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 }> = [];
|
||
let collaboratorSearch = '';
|
||
let collaboratorResults: Array<{ id: string; login: string; display_name: string }> = [];
|
||
let collaboratorSearchBusy = false;
|
||
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; 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 = '';
|
||
let commandFormName = '';
|
||
let commandFormDescription = '';
|
||
let commandFormPermission = 'everyone';
|
||
let commandFormAliases = '';
|
||
let commandFormTags = '';
|
||
let commandFormCooldown = 0;
|
||
let commandFormCooldownScope = 'global';
|
||
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 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 = '';
|
||
let overlayHtml = '';
|
||
let overlayCss = '';
|
||
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; 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 },
|
||
bot: { connected: false, missing_scopes: [], reauthorization_required: false },
|
||
};
|
||
|
||
const CLIENT_VERSION = '0.1.0';
|
||
|
||
function versionParts(value: string) {
|
||
const match = value.match(/^(\d+)\.(\d+)\.(\d+)/);
|
||
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);
|
||
const maximumParts = versionParts(maximum);
|
||
if (!client || !minimumParts) return false;
|
||
const compare = (left: number[], right: number[]) => left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
|
||
if (compare(client, minimumParts) < 0) return false;
|
||
if (maximum === '0.x' || !maximumParts) return true;
|
||
return compare(client, maximumParts) <= 0;
|
||
}
|
||
|
||
$: if (authenticated && selectedOverlayId && overlayName && !overlayBusy) {
|
||
const signature = overlayDraftSignature();
|
||
cacheOverlayDraft();
|
||
if (signature !== overlaySavedSignature && signature !== overlayAutosaveAttemptedSignature && !overlayAutosaveTimer) {
|
||
overlayAutosaveAttemptedSignature = signature;
|
||
overlayAutosaveTimer = window.setTimeout(() => {
|
||
overlayAutosaveTimer = undefined;
|
||
void saveOverlay();
|
||
}, 1000);
|
||
}
|
||
}
|
||
|
||
async function checkServer() {
|
||
const generation = ++connectionCheckGeneration;
|
||
connectionCheckRunning = true;
|
||
connectionState = 'CONNECTING';
|
||
let attempt = 0;
|
||
while (true) {
|
||
const delay = attempt === 0 ? 0 : attempt === 1 ? 3000 : attempt === 2 ? 10000 : attempt === 3 ? 15000 : 30000;
|
||
if (delay > 0) {
|
||
connectionState = 'RECONNECTING';
|
||
connectionNotice = `Retry ${attempt + 1} in ${delay / 1000}s`;
|
||
await new Promise((resolve) => window.setTimeout(resolve, delay));
|
||
} 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}`);
|
||
const info: { server_version: string; minimum_client_version?: string; maximum_client_version?: string } = await response.json();
|
||
serverVersion = info.server_version;
|
||
if (info.minimum_client_version && info.maximum_client_version && !isClientCompatible(info.minimum_client_version, info.maximum_client_version)) {
|
||
serverCompatibility = `Requires client ${info.minimum_client_version}–${info.maximum_client_version}`;
|
||
connectionState = 'INCOMPATIBLE';
|
||
connectionNotice = 'Client update required';
|
||
connectionCheckRunning = false;
|
||
return;
|
||
}
|
||
serverCompatibility = info.minimum_client_version && info.maximum_client_version ? `${info.minimum_client_version}–${info.maximum_client_version}` : '';
|
||
connectionState = 'CONNECTED';
|
||
connectionNotice = '';
|
||
connectionCheckRunning = false;
|
||
return;
|
||
} catch {
|
||
connectionState = 'SERVER UNAVAILABLE';
|
||
connectionNotice = 'Server unavailable';
|
||
}
|
||
attempt += 1;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
authBusy = true;
|
||
authError = '';
|
||
try {
|
||
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) {
|
||
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;
|
||
mustReplaceCredentials = result.must_replace_credentials;
|
||
await saveNativeSession().catch(() => {
|
||
authError = 'Login succeeded, but the session could not be stored in the Linux keyring.';
|
||
});
|
||
connectionState = 'CONNECTED';
|
||
try {
|
||
await loadAuthenticatedData();
|
||
} catch {
|
||
authError = 'Login succeeded, but some dashboard data could not be loaded.';
|
||
}
|
||
if (!mustReplaceCredentials) password = '';
|
||
if (mustReplaceCredentials) newUsername = username;
|
||
} catch (error) {
|
||
authError = error instanceof Error ? error.message : 'The server could not process the login.';
|
||
authenticated = false;
|
||
} finally {
|
||
authBusy = false;
|
||
}
|
||
}
|
||
|
||
async function loadBackups() {
|
||
backupError = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/admin/backups`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
||
if (!response.ok) throw new Error('Backup list unavailable.');
|
||
backups = await response.json();
|
||
} catch (error) {
|
||
backupError = error instanceof Error ? error.message : 'Backup list unavailable.';
|
||
}
|
||
}
|
||
|
||
async function runBackup() {
|
||
backupBusy = true;
|
||
backupError = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/admin/backups/run`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
|
||
const result: { status: string; reason?: string | null } = await response.json();
|
||
if (!response.ok || result.status !== 'ok') throw new Error(result.reason || 'Backup failed.');
|
||
await loadBackups();
|
||
} catch (error) {
|
||
backupError = error instanceof Error ? error.message : 'Backup failed.';
|
||
} finally {
|
||
backupBusy = false;
|
||
}
|
||
}
|
||
|
||
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 = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/auth/credentials`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
|
||
body: JSON.stringify({ current_password: password, username: newUsername, password: newPassword }),
|
||
});
|
||
if (!response.ok) throw new Error(response.status === 400 ? 'Use a username and a password of at least 10 characters.' : 'Credential replacement failed.');
|
||
sessionToken = '';
|
||
authenticated = false;
|
||
mustReplaceCredentials = false;
|
||
await clearNativeSession().catch(() => undefined);
|
||
username = newUsername;
|
||
password = '';
|
||
newPassword = '';
|
||
authError = 'Credentials updated. Sign in again.';
|
||
} catch (error) {
|
||
authError = error instanceof Error ? error.message : 'Credential replacement failed.';
|
||
} finally {
|
||
authBusy = false;
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
sessionRestoreGeneration += 1;
|
||
clearTimeout(sessionRestoreTimer);
|
||
realtimeSocket?.close();
|
||
realtimeSocket = undefined;
|
||
realtimeState = 'DISCONNECTED';
|
||
if (sessionToken) {
|
||
await fetch(`${serverAddress}/api/v1/auth/logout`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } }).catch(() => undefined);
|
||
}
|
||
await clearNativeSession().catch(() => undefined);
|
||
sessionToken = '';
|
||
authenticated = false;
|
||
mustReplaceCredentials = false;
|
||
oauthLink = '';
|
||
notifications = [];
|
||
showNotifications = false;
|
||
diagnostics = undefined;
|
||
chatMessages = [];
|
||
collaborationSession = null;
|
||
collaborationInvites = [];
|
||
collaborationSettings = null;
|
||
showCollaboration = false;
|
||
chatReplyParent = undefined;
|
||
chatThreadRoot = undefined;
|
||
pinnedMessageIds = [];
|
||
plugins = [];
|
||
}
|
||
|
||
function connectRealtime() {
|
||
realtimeSocket?.close();
|
||
const safeServerAddress = validatedServerAddress(serverAddress);
|
||
const socketUrl = safeServerAddress.replace(/^http/, 'ws') + '/api/v1/ws';
|
||
realtimeState = 'CONNECTING';
|
||
const socket = new WebSocket(socketUrl);
|
||
realtimeSocket = socket;
|
||
socket.onopen = () => {
|
||
socket.send(JSON.stringify({ frame_type: 'authenticate', token: sessionToken }));
|
||
};
|
||
socket.onmessage = (message) => {
|
||
try {
|
||
const event: { event_type?: string } = JSON.parse(message.data);
|
||
if (event.event_type === 'connection.ready') realtimeState = 'CONNECTED';
|
||
else if ('event_id' in event && event.event_type) {
|
||
const activity = event as { event_id: string; event_type: string; occurred_at: string; payload?: { message_id?: string; chatter_user_id?: string; chatter_user_name?: string; message?: { text?: string; fragments?: Array<{ type: string; text: string; emote?: { id?: string } }> }; reply?: { parent_message_id?: string } } };
|
||
if (!activityEvents.some((item) => item.event_id === activity.event_id)) {
|
||
activityEvents = [activity, ...activityEvents].slice(0, 100);
|
||
}
|
||
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';
|
||
}
|
||
};
|
||
socket.onclose = () => {
|
||
realtimeState = 'DISCONNECTED';
|
||
if (realtimeSocket === socket) realtimeSocket = undefined;
|
||
};
|
||
socket.onerror = () => {
|
||
realtimeState = 'DISCONNECTED';
|
||
};
|
||
}
|
||
|
||
async function loadActivity() {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/activity?limit=100`, {
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (!response.ok) return;
|
||
activityEvents = await response.json();
|
||
chatMessages = [];
|
||
} catch {
|
||
activityEvents = [];
|
||
}
|
||
}
|
||
|
||
async function loadNotifications() {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/admin/notifications`, {
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (!response.ok) return;
|
||
notifications = await response.json();
|
||
} catch {
|
||
notifications = [];
|
||
}
|
||
}
|
||
|
||
async function loadDiagnostics() {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/server/diagnostics`, {
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (!response.ok) return;
|
||
diagnostics = await response.json();
|
||
} catch {
|
||
diagnostics = undefined;
|
||
}
|
||
}
|
||
|
||
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();
|
||
if (collaborationSession?.id) {
|
||
const invites = await fetch(`${serverAddress}/api/v1/twitch/collaboration/invites?session_id=${encodeURIComponent(collaborationSession.id)}`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
||
if (invites.ok) collaborationInvites = await invites.json();
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
async function loadCollaborators() {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/collaborators`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
||
if (response.ok) collaborators = await response.json();
|
||
} catch {
|
||
collaborators = [];
|
||
}
|
||
}
|
||
|
||
async function searchCollaborators(value: string) {
|
||
collaboratorSearch = value;
|
||
collaboratorSearchError = '';
|
||
collaboratorResults = [];
|
||
if (collaboratorSearchTimer) window.clearTimeout(collaboratorSearchTimer);
|
||
if (!value.trim()) return;
|
||
collaboratorSearchTimer = window.setTimeout(async () => {
|
||
collaboratorSearchBusy = true;
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/search?query=${encodeURIComponent(value.trim())}`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
||
if (!response.ok) throw new Error('Twitch user search failed.');
|
||
collaboratorResults = await response.json();
|
||
} catch (error) {
|
||
collaboratorSearchError = error instanceof Error ? error.message : 'Twitch user search failed.';
|
||
} finally {
|
||
collaboratorSearchBusy = false;
|
||
}
|
||
}, 200);
|
||
}
|
||
|
||
function clearCollaboratorSearch() {
|
||
if (collaboratorSearchTimer) window.clearTimeout(collaboratorSearchTimer);
|
||
collaboratorSearch = '';
|
||
collaboratorResults = [];
|
||
collaboratorSearchError = '';
|
||
}
|
||
|
||
async function inviteCollaborator(user: { id: string; display_name: string }) {
|
||
collaborationGuestId = user.id;
|
||
await sendCollaborationInvite();
|
||
clearCollaboratorSearch();
|
||
}
|
||
|
||
async function toggleCollaboratorFavorite(collaborator: (typeof collaborators)[number]) {
|
||
const method = collaborator.favorite ? 'DELETE' : 'POST';
|
||
const url = collaborator.favorite ? `${serverAddress}/api/v1/twitch/collaboration/favorites?twitch_user_id=${encodeURIComponent(collaborator.twitch_user_id)}` : `${serverAddress}/api/v1/twitch/collaboration/favorites`;
|
||
const response = await fetch(url, { method, headers: { authorization: `Bearer ${sessionToken}`, ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) }, ...(method === 'POST' ? { body: JSON.stringify({ twitch_user_id: collaborator.twitch_user_id, display_name: collaborator.display_name }) } : {}) });
|
||
if (response.ok) await loadCollaborators();
|
||
}
|
||
|
||
async function createCollaboration() {
|
||
collaborationBusy = true;
|
||
collaborationError = '';
|
||
try {
|
||
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.';
|
||
} finally {
|
||
collaborationBusy = false;
|
||
}
|
||
}
|
||
|
||
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 {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/session/end?session_id=${encodeURIComponent(collaborationSession.id)}`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
|
||
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 {
|
||
collaborationBusy = false;
|
||
}
|
||
}
|
||
|
||
async function sendCollaborationInvite() {
|
||
if (!collaborationSession || !collaborationGuestId.trim()) return;
|
||
collaborationBusy = true;
|
||
collaborationError = '';
|
||
try {
|
||
const query = `session_id=${encodeURIComponent(collaborationSession.id)}&guest_id=${encodeURIComponent(collaborationGuestId.trim())}`;
|
||
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.';
|
||
} finally {
|
||
collaborationBusy = false;
|
||
}
|
||
}
|
||
|
||
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.';
|
||
} finally {
|
||
collaborationBusy = false;
|
||
}
|
||
}
|
||
|
||
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',
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (response.ok) {
|
||
notifications = notifications.map((notification) => notification.id === notificationId ? { ...notification, seen_at: new Date().toISOString() } : notification);
|
||
}
|
||
}
|
||
|
||
async function loadStreamInfo() {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/stream-info`, {
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (!response.ok) return;
|
||
const info: { broadcaster_name: string; title: string; game_id: string; game_name: string; language: string; tags: string[]; content_classification_labels: string[] } = await response.json();
|
||
streamerName = info.broadcaster_name;
|
||
playerSrc = `https://player.twitch.tv/?channel=${encodeURIComponent(info.broadcaster_name)}&parent=${encodeURIComponent(window.location.hostname)}&muted=true`;
|
||
streamTitle = info.title;
|
||
streamGameId = info.game_id;
|
||
categorySearch = info.game_name;
|
||
streamLanguage = info.language;
|
||
streamTags = info.tags.join(', ');
|
||
streamClassificationLabels = info.content_classification_labels.join(', ');
|
||
} catch {
|
||
// A missing Twitch streamer identity is shown when the editor is opened.
|
||
}
|
||
}
|
||
|
||
function searchCategories(value: string) {
|
||
categorySearch = value;
|
||
categoryOptions = [];
|
||
if (categorySearchTimer) window.clearTimeout(categorySearchTimer);
|
||
if (!value.trim()) return;
|
||
categorySearchTimer = window.setTimeout(async () => {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/games?query=${encodeURIComponent(value.trim())}`, {
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (response.ok) categoryOptions = await response.json();
|
||
} catch {
|
||
categoryOptions = [];
|
||
}
|
||
}, 200);
|
||
}
|
||
|
||
async function saveStreamInfo() {
|
||
streamBusy = true;
|
||
streamError = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/stream-info`, {
|
||
method: 'PATCH',
|
||
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
|
||
body: JSON.stringify({
|
||
title: streamTitle,
|
||
game_id: streamGameId,
|
||
language: streamLanguage,
|
||
tags: streamTags.split(',').map((tag) => tag.trim()).filter(Boolean),
|
||
content_classification_labels: streamClassificationLabels.split(',').map((label) => label.trim()).filter(Boolean),
|
||
}),
|
||
});
|
||
if (!response.ok) throw new Error('The server could not update stream information.');
|
||
showStreamEditor = false;
|
||
} catch (error) {
|
||
streamError = error instanceof Error ? error.message : 'Stream information update failed.';
|
||
} finally {
|
||
streamBusy = false;
|
||
}
|
||
}
|
||
|
||
async function sendChatMessage() {
|
||
if (!chatMessage.trim() || chatMessage.length > 500) return;
|
||
chatBusy = true;
|
||
chatError = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/chat/message`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
|
||
body: JSON.stringify({ identity: chatIdentity, message: chatMessage, reply_parent_message_id: chatReplyParent?.message_id }),
|
||
});
|
||
if (!response.ok) throw new Error('The selected Twitch identity could not send the message.');
|
||
chatMessage = '';
|
||
chatReplyParent = undefined;
|
||
} catch (error) {
|
||
chatError = error instanceof Error ? error.message : 'Chat message failed.';
|
||
} finally {
|
||
chatBusy = false;
|
||
}
|
||
}
|
||
|
||
function searchChatEmotes(value: string) {
|
||
const match = value.match(/(^|\s):([A-Za-z0-9_]{1,64})$/);
|
||
chatEmoteOptions = [];
|
||
if (chatEmoteTimer) window.clearTimeout(chatEmoteTimer);
|
||
if (!match) return;
|
||
const query = match[2];
|
||
chatEmoteTimer = window.setTimeout(async () => {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/chat/emotes?query=${encodeURIComponent(query)}`, {
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (response.ok) chatEmoteOptions = await response.json();
|
||
} catch {
|
||
chatEmoteOptions = [];
|
||
}
|
||
}, 200);
|
||
}
|
||
|
||
function selectChatEmote(name: string) {
|
||
const match = chatMessage.match(/(^|\s):[A-Za-z0-9_]{1,64}$/);
|
||
if (!match) return;
|
||
chatMessage = `${chatMessage.slice(0, chatMessage.length - match[0].length)}${match[1]}${name} `;
|
||
chatEmoteOptions = [];
|
||
}
|
||
|
||
function replyToChatMessage(message: { message_id: string; user_name: string }) {
|
||
chatReplyParent = { message_id: message.message_id, user_name: message.user_name };
|
||
}
|
||
|
||
function openChatThread(message: { message_id: string; user_name: string; text: string }) {
|
||
chatThreadRoot = { message_id: message.message_id, user_name: message.user_name, text: message.text };
|
||
}
|
||
|
||
function moderateChatUser(userId: string) {
|
||
if (!userId) return;
|
||
moderationUserId = userId;
|
||
showModeration = true;
|
||
}
|
||
|
||
function togglePinnedMessage(messageId: string) {
|
||
pinnedMessageIds = pinnedMessageIds.includes(messageId) ? pinnedMessageIds.filter((id) => id !== messageId) : [messageId, ...pinnedMessageIds];
|
||
}
|
||
|
||
function deleteChatMessage(message: { message_id: string; user_id: string }) {
|
||
moderationAction = 'delete_message';
|
||
moderationUserId = message.user_id;
|
||
moderationMessageId = message.message_id;
|
||
showModeration = true;
|
||
}
|
||
|
||
async function applyModeration() {
|
||
moderationBusy = true;
|
||
moderationError = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/moderation/action`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
|
||
body: JSON.stringify({ action: moderationAction, user_id: moderationUserId, duration_seconds: moderationDuration, reason: moderationReason, message_id: moderationMessageId }),
|
||
});
|
||
if (!response.ok) throw new Error('The moderation action could not be applied.');
|
||
moderationUserId = '';
|
||
moderationMessageId = '';
|
||
moderationReason = '';
|
||
} catch (error) {
|
||
moderationError = error instanceof Error ? error.message : 'Moderation action failed.';
|
||
} finally {
|
||
moderationBusy = false;
|
||
}
|
||
}
|
||
|
||
async function createOAuthLink(identity: 'streamer' | 'bot') {
|
||
oauthBusy = true;
|
||
oauthError = '';
|
||
oauthIdentity = identity;
|
||
oauthLink = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/oauth/link?identity=${identity}`, {
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (!response.ok) throw new Error('The server could not create the Twitch authorization link.');
|
||
const result: { authorization_url: string } = await response.json();
|
||
oauthLink = result.authorization_url;
|
||
} catch (error) {
|
||
oauthError = error instanceof Error ? error.message : 'The Twitch authorization link could not be created.';
|
||
} finally {
|
||
oauthBusy = false;
|
||
}
|
||
}
|
||
|
||
async function loadOAuthStatus() {
|
||
for (const identity of ['streamer', 'bot'] as const) {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/oauth/status?identity=${identity}`, {
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (!response.ok) continue;
|
||
const result = await response.json();
|
||
oauthScopes = { ...oauthScopes, [identity]: result };
|
||
} catch {
|
||
// The diagnostics panel remains usable if scope status is temporarily unavailable.
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadBotCommands() {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/bot/commands`);
|
||
if (!response.ok) throw new Error('Commands could not be loaded.');
|
||
botCommands = await response.json();
|
||
botCommandError = '';
|
||
} catch (error) {
|
||
botCommandError = error instanceof Error ? error.message : 'Commands could not be loaded.';
|
||
}
|
||
}
|
||
|
||
function resetCommandForm() {
|
||
commandFormId = '';
|
||
commandFormName = '';
|
||
commandFormDescription = '';
|
||
commandFormPermission = 'everyone';
|
||
commandFormAliases = '';
|
||
commandFormTags = '';
|
||
commandFormCooldown = 0;
|
||
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]) {
|
||
commandFormId = command.id;
|
||
commandFormName = command.name;
|
||
commandFormDescription = command.description;
|
||
commandFormPermission = command.permission_level;
|
||
commandFormAliases = command.aliases.join(', ');
|
||
commandFormTags = command.tags.join(', ');
|
||
commandFormCooldown = command.cooldown_seconds;
|
||
commandFormCooldownScope = command.cooldown_scope;
|
||
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() {
|
||
commandFormBusy = true;
|
||
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, 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();
|
||
await loadBotCommands();
|
||
await loadPublicCommands();
|
||
} catch (error) {
|
||
botCommandError = error instanceof Error ? error.message : 'The command could not be saved.';
|
||
} finally {
|
||
commandFormBusy = false;
|
||
}
|
||
}
|
||
|
||
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 = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/bot/commands/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
|
||
if (!response.ok) throw new Error('The command could not be deleted.');
|
||
if (commandFormId === id) resetCommandForm();
|
||
await loadBotCommands();
|
||
await loadPublicCommands();
|
||
} catch (error) {
|
||
botCommandError = error instanceof Error ? error.message : 'The command could not be deleted.';
|
||
}
|
||
}
|
||
|
||
async function loadPublicCommands() {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/bot/commands`);
|
||
if (response.ok) publicBotCommands = await response.json();
|
||
} catch {
|
||
publicBotCommands = [];
|
||
}
|
||
}
|
||
|
||
async function loadBotSecrets() {
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/bot/secrets`, { headers: { authorization: `Bearer ${sessionToken}` } });
|
||
if (!response.ok) throw new Error('Secrets could not be loaded.');
|
||
botSecrets = await response.json();
|
||
secretError = '';
|
||
} catch (error) {
|
||
secretError = error instanceof Error ? error.message : 'Secrets could not be loaded.';
|
||
}
|
||
}
|
||
|
||
async function createSecret() {
|
||
secretError = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/bot/secrets`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ name: secretName, description: secretDescription, value: secretValue }) });
|
||
if (!response.ok) throw new Error('The secret could not be saved.');
|
||
secretName = '';
|
||
secretDescription = '';
|
||
secretValue = '';
|
||
await loadBotSecrets();
|
||
} catch (error) {
|
||
secretError = error instanceof Error ? error.message : 'The secret could not be saved.';
|
||
}
|
||
}
|
||
|
||
async function deleteSecret(id: string) {
|
||
secretError = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/bot/secrets/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
|
||
if (!response.ok) throw new Error('The secret could not be deleted.');
|
||
await loadBotSecrets();
|
||
} catch (error) {
|
||
secretError = error instanceof Error ? error.message : 'The secret could not be deleted.';
|
||
}
|
||
}
|
||
|
||
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}` } });
|
||
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]) {
|
||
pendingOverlayDraft = null;
|
||
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;
|
||
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, overlayAlertGroupWindowMs, overlayAlertGroupSize, overlayAlertDisplayMs });
|
||
}
|
||
|
||
function overlayVariableWarnings() {
|
||
const warnings: string[] = [];
|
||
for (const [language, source] of [['HTML', overlayHtml], ['CSS', overlayCss], ['JAVASCRIPT', overlayJavascript]] as const) {
|
||
if (source.includes('${')) {
|
||
for (const match of source.match(/\$\{([^}]*)\}/g) || []) {
|
||
const name = match.slice(2, -1);
|
||
if (!/^[A-Za-z0-9_]+$/.test(name) || name.length === 0) warnings.push(`${language}: invalid variable ${match[0]}`);
|
||
else if (name !== 'username') warnings.push(`${language}: undefined variable ${match[0]}`);
|
||
}
|
||
if (source.split('${').length - 1 !== (source.match(/\$\{[^}]*\}/g) || []).length) warnings.push(`${language}: an opening variable marker has no closing }`);
|
||
}
|
||
}
|
||
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}`;
|
||
}
|
||
|
||
function cacheOverlayDraft() {
|
||
if (!selectedOverlayId) return;
|
||
try {
|
||
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.
|
||
}
|
||
}
|
||
|
||
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; overlayAlertGroupWindowMs: number; overlayAlertGroupSize: number; overlayAlertDisplayMs: number }> | null;
|
||
if (!cached || typeof cached.overlayName !== 'string') return;
|
||
const cachedSignature = JSON.stringify(cached);
|
||
if (cachedSignature !== overlayDraftSignature()) pendingOverlayDraft = {
|
||
overlayName: cached.overlayName,
|
||
overlaySlug: cached.overlaySlug ?? overlaySlug,
|
||
overlayHtml: cached.overlayHtml ?? overlayHtml,
|
||
overlayCss: cached.overlayCss ?? overlayCss,
|
||
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.
|
||
}
|
||
}
|
||
|
||
async function createOverlay() {
|
||
if (overlayAutosaveTimer) window.clearTimeout(overlayAutosaveTimer);
|
||
overlayAutosaveTimer = undefined;
|
||
overlaySavedSignature = '';
|
||
overlayAutosaveAttemptedSignature = '';
|
||
pendingOverlayDraft = null;
|
||
selectedOverlayId = '';
|
||
overlayName = 'New Overlay';
|
||
overlaySlug = `overlay-${Date.now()}`;
|
||
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, overlayAlertGroupWindowMs, overlayAlertGroupSize, overlayAlertDisplayMs } = pendingOverlayDraft);
|
||
pendingOverlayDraft = null;
|
||
overlaySavedSignature = '';
|
||
overlayAutosaveAttemptedSignature = '';
|
||
}
|
||
|
||
function discardPendingOverlayDraft() {
|
||
if (!window.confirm('Discard the cached local overlay draft? This cannot be undone.')) return;
|
||
if (selectedOverlayId) localStorage.removeItem(overlayDraftCacheKey(selectedOverlayId));
|
||
pendingOverlayDraft = null;
|
||
}
|
||
|
||
async function saveOverlay() {
|
||
overlayBusy = true;
|
||
overlayError = '';
|
||
try {
|
||
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();
|
||
await loadOverlays();
|
||
selectOverlay({ ...saved, enabled: overlayEnabled, disabled_design: overlayDisabledDesign });
|
||
if (selectedOverlayId) localStorage.removeItem(overlayDraftCacheKey(selectedOverlayId));
|
||
} 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 deleteOverlay() {
|
||
if (!selectedOverlayId || !window.confirm(`Permanently delete overlay “${overlayName}”? Its browser-source URL will stop serving content.`)) return;
|
||
overlayBusy = true;
|
||
overlayError = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/overlays/${selectedOverlayId}`, { method: 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
|
||
if (!response.ok) throw new Error('The overlay could not be deleted.');
|
||
selectedOverlayId = '';
|
||
overlayName = '';
|
||
overlaySlug = '';
|
||
overlayHtml = '';
|
||
overlayCss = '';
|
||
overlayJavascript = '';
|
||
pendingOverlayDraft = null;
|
||
await loadOverlays();
|
||
} catch (error) {
|
||
overlayError = error instanceof Error ? error.message : 'The overlay could not be deleted.';
|
||
} finally {
|
||
overlayBusy = false;
|
||
}
|
||
}
|
||
|
||
async function revokeOAuth(identity: 'streamer' | 'bot') {
|
||
oauthBusy = true;
|
||
oauthError = '';
|
||
try {
|
||
const response = await fetch(`${serverAddress}/api/v1/twitch/oauth/revoke?identity=${identity}`, {
|
||
method: 'POST',
|
||
headers: { authorization: `Bearer ${sessionToken}` },
|
||
});
|
||
if (!response.ok) throw new Error('The server could not revoke this Twitch authorization.');
|
||
await loadOAuthStatus();
|
||
await loadStreamInfo();
|
||
} catch (error) {
|
||
oauthError = error instanceof Error ? error.message : 'The Twitch authorization could not be revoked.';
|
||
} finally {
|
||
oauthBusy = false;
|
||
}
|
||
}
|
||
|
||
async function copyOAuthLink() {
|
||
if (!oauthLink) return;
|
||
await navigator.clipboard.writeText(oauthLink).catch(() => {
|
||
oauthError = 'Copy was unavailable. Select and copy the link manually.';
|
||
});
|
||
}
|
||
|
||
onMount(async () => {
|
||
await loadNativeSettings();
|
||
void checkServer();
|
||
void loadPublicCommands();
|
||
void restoreNativeSession();
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>Trixie</title></svelte:head>
|
||
|
||
<div class="app-shell">
|
||
<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', 'Plugins', 'Collaboration', 'Overlays', 'Settings'] as item}
|
||
<button class:active={activeView === item} class="nav-item" onclick={() => (activeView = item)}>{item}</button>
|
||
{/each}
|
||
</nav>
|
||
<div class="app-actions"><span class:online={connectionState === 'CONNECTED'} class="connection-state">{connectionState}</span>{#if connectionNotice}<span class="connection-notice">{connectionNotice}</span>{/if}{#if authenticated}<div class="notification-wrap"><button class="icon-button notification-button" aria-label="Notifications" onclick={() => (showNotifications = !showNotifications)}>♢{#if notifications.some((notification) => !notification.seen_at)}<span class="notification-dot"></span>{/if}</button>{#if showNotifications}<div class="notification-popover"><div class="notification-heading"><span>NOTIFICATIONS</span><button class="secondary" onclick={loadNotifications}>↻</button></div>{#if notifications.length}{#each notifications as notification}<div class:unseen={!notification.seen_at} class="notification-item"><div class="notification-title"><span>{notification.title}</span><span class="micro">{notification.severity}</span></div><div class="notification-message">{notification.message}</div>{#if !notification.seen_at}<button class="secondary-action" onclick={() => markNotificationSeen(notification.id)}>MARK SEEN</button>{/if}</div>{/each}{:else}<div class="notification-empty">No notifications.</div>{/if}</div>{/if}</div>{/if}<button class="icon-button" aria-label="Settings">⚙</button></div>
|
||
</header>
|
||
|
||
<main class="workspace">
|
||
<aside class="side-panel">
|
||
<div class="panel-header"><span>TRIXIE</span><span class="panel-kicker">CONTROL</span></div>
|
||
<div class="server-card">
|
||
<div class="micro">SERVER CONNECTION</div>
|
||
<div class="server-status"><span class:good={connectionState === 'CONNECTED'} class="status-dot"></span>{connectionState}</div>
|
||
<label for="server-address" class="micro">ADDRESS</label>
|
||
<input id="server-address" class="control" bind:value={serverAddress} spellcheck="false" />
|
||
<button class="primary control-button" onclick={checkServer}>TEST CONNECTION</button>
|
||
</div>
|
||
<div class="server-card auth-card">
|
||
<div class="micro">ADMINISTRATOR ACCESS</div>
|
||
{#if authenticated}
|
||
<div class="server-status"><span class="status-dot good"></span>AUTHENTICATED</div>
|
||
<button class="primary control-button" onclick={logout}>LOG OUT</button>
|
||
{:else}
|
||
<label for="username" class="micro">USERNAME</label>
|
||
<input id="username" class="control" bind:value={username} autocomplete="username" />
|
||
<label for="password" class="micro">PASSWORD</label>
|
||
<input id="password" class="control" type="password" bind:value={password} autocomplete="current-password" />
|
||
<button class="primary control-button" onclick={login} disabled={authBusy}>LOG IN</button>
|
||
{#if authError}<div class="auth-error">{authError}</div>{/if}
|
||
{/if}
|
||
</div>
|
||
<div class="side-section channel-section">
|
||
<div class="micro">TWITCH ACCOUNTS</div>
|
||
{#if authenticated}
|
||
<div class="oauth-actions">
|
||
<button class="secondary-action" onclick={() => createOAuthLink('streamer')} disabled={oauthBusy}>CONNECT STREAMER</button>
|
||
<button class="secondary-action" onclick={() => revokeOAuth('streamer')} disabled={oauthBusy}>DISCONNECT STREAMER</button>
|
||
<button class="secondary-action" onclick={() => createOAuthLink('bot')} disabled={oauthBusy}>CONNECT BOT</button>
|
||
<button class="secondary-action" onclick={() => revokeOAuth('bot')} disabled={oauthBusy}>DISCONNECT BOT</button>
|
||
<button class="secondary-action" onclick={loadOAuthStatus} disabled={oauthBusy}>REFRESH SCOPE STATUS</button>
|
||
</div>
|
||
<div class="oauth-status-grid">
|
||
{#each ['streamer', 'bot'] as identity}
|
||
<div class="oauth-status-row"><span class="micro">{identity.toUpperCase()}</span><span class:good={oauthScopes[identity as 'streamer' | 'bot'].connected} class="mono">{oauthScopes[identity as 'streamer' | 'bot'].connected ? 'READY' : oauthScopes[identity as 'streamer' | 'bot'].reauthorization_required ? 'REAUTHORIZE' : 'NOT CONNECTED'}</span></div>
|
||
{#if oauthScopes[identity as 'streamer' | 'bot'].missing_scopes.length}<div class="oauth-hint">Missing: {oauthScopes[identity as 'streamer' | 'bot'].missing_scopes.join(', ')}</div>{/if}
|
||
{/each}
|
||
</div>
|
||
{#if oauthLink}
|
||
<div class="oauth-link-card">
|
||
<div class="micro">{oauthIdentity.toUpperCase()} AUTHORIZATION LINK</div>
|
||
<textarea class="oauth-link" readonly value={oauthLink} aria-label="Twitch authorization link"></textarea>
|
||
<button class="primary control-button" onclick={copyOAuthLink}>COPY LINK</button>
|
||
<div class="oauth-hint">Open the copied link in the intended Twitch account session. It expires in 10 minutes.</div>
|
||
</div>
|
||
{/if}
|
||
{#if oauthError}<div class="auth-error">{oauthError}</div>{/if}
|
||
{:else}
|
||
<div class="empty-row">Sign in as administrator to connect accounts.</div>
|
||
{/if}
|
||
</div>
|
||
<div class="side-section side-footer"><div class="micro">REALTIME</div><div class="mono">{realtimeState}</div>{#if diagnostics}<div class="micro version-label">DATABASE</div><div class="mono">{diagnostics.database.toUpperCase()}</div><div class="micro version-label">TWITCH IDENTITIES</div><div class="mono">{diagnostics.twitch_identity_count}</div>{/if}<div class="micro version-label">SERVER VERSION</div><div class="mono">{serverVersion}</div></div>
|
||
</aside>
|
||
|
||
<section class="content-area">
|
||
{#if !authenticated}
|
||
<section class="auth-gate panel">
|
||
<div class="panel-header"><span>AUTHENTICATION REQUIRED</span></div>
|
||
<div class="auth-gate-body"><div class="empty-glyph">⌁</div><strong>Sign in to Trixie</strong><span>The server connection is ready, but the dashboard requires administrator authentication.</span></div>
|
||
</section>
|
||
{:else if mustReplaceCredentials}
|
||
<section class="auth-gate panel">
|
||
<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="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} 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-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 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>
|
||
<section class="panel activity-panel"><div class="panel-header"><span>ACTIVITY FEED</span><span class="panel-menu">⋮</span></div><div class="panel-tabs"><button class:active={activityFilter === 'all'} class="tab" onclick={() => (activityFilter = 'all')}>ALL ACTIVITY</button><button class:active={activityFilter === 'moderation'} class="tab" onclick={() => (activityFilter = 'moderation')}>MODERATION</button></div>{#if activityEvents.filter((event) => activityFilter === 'all' || event.event_type.startsWith('moderation.')).length}<div class="activity-list">{#each activityEvents.filter((event) => activityFilter === 'all' || event.event_type.startsWith('moderation.')) as event}<div class="activity-row"><span class="activity-type">{event.event_type}</span><span class="activity-time">{new Date(event.occurred_at).toLocaleTimeString()}</span></div>{/each}</div>{:else}<div class="empty-state"><div class="empty-glyph">◌</div><strong>Nothing here yet</strong><span>{activityFilter === 'moderation' ? 'Moderation events will appear here.' : 'Follows, subscriptions, raids, and server events will appear here.'}</span></div>{/if}</section>
|
||
<section class="panel chat-panel"><div class="panel-header"><span>MY CHAT</span><span class="panel-menu">⋮</span></div>{#if pinnedMessageIds.length}<div class="pinned-messages"><span class="micro">PINNED</span>{#each chatMessages.filter((message) => pinnedMessageIds.includes(message.message_id)) as message}<div class="pinned-message"><span class="chat-user">{message.user_name}</span><span>{message.text}</span></div>{/each}</div>{/if}{#if chatMessages.length}<div class="chat-message-list">{#each chatMessages as message}<div class:message-pinned={pinnedMessageIds.includes(message.message_id)} class="chat-message"><button class="chat-user" onclick={() => moderateChatUser(message.user_id)}>{message.user_name}</button><span class="chat-text">{#if message.fragments.length}{#each message.fragments as fragment}{#if fragment.type === 'emote' && fragment.emote_id}<img class="chat-emote" src={`https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(fragment.emote_id)}/static/dark/2.0`} alt={fragment.text} />{:else}{fragment.text}{/if}{/each}{:else}{message.text}{/if}</span><span class="chat-actions"><button class="chat-reply" onclick={() => togglePinnedMessage(message.message_id)}>{pinnedMessageIds.includes(message.message_id) ? 'UNPIN' : 'PIN'}</button><button class="chat-reply" onclick={() => replyToChatMessage(message)}>REPLY</button><button class="chat-reply danger" onclick={() => deleteChatMessage(message)}>DELETE</button></span></div>{/each}</div>{:else}<div class="chat-empty"><div class="empty-glyph">⌁</div><strong>Chat is waiting</strong><span>Connect a streamer or bot account to begin.</span></div>{/if}<div class="chat-composer">{#if chatReplyParent}<div class="chat-reply-context">REPLYING TO @{chatReplyParent.user_name}<button class="secondary" onclick={() => (chatReplyParent = undefined)}>×</button></div>{/if}<div class="chat-identity-toggle"><button class:active={chatIdentity === 'streamer'} class="chat-identity" onclick={() => (chatIdentity = 'streamer')}>STREAMER</button><button class:active={chatIdentity === 'bot'} class="chat-identity" onclick={() => (chatIdentity = 'bot')}>BOT</button></div>{#if chatEmoteOptions.length}<div class="chat-emote-picker">{#each chatEmoteOptions as emote}<button class="chat-emote-option" onclick={() => selectChatEmote(emote.name)}><img src={emote.url} alt={emote.name} /><span>{emote.name}</span><span class="micro">{emote.provider}</span></button>{/each}</div>{/if}<div class="chat-send-row"><input class="control" bind:value={chatMessage} maxlength="500" placeholder={`Send as ${chatIdentity}`} oninput={() => searchChatEmotes(chatMessage)} onkeydown={(event) => event.key === 'Enter' && sendChatMessage()} /><button class="primary" onclick={sendChatMessage} disabled={chatBusy || !chatMessage.trim()}>SEND</button></div>{#if chatError}<div class="auth-error">{chatError}</div>{/if}</div></section>
|
||
<section class="panel quick-panel"><div class="panel-header"><span>QUICK ACTIONS</span><span class="panel-menu">⋮</span></div><div class="quick-grid"><button class="quick-action accent" onclick={() => (showStreamEditor = !showStreamEditor)}>EDIT STREAM INFO</button><button class="quick-action" onclick={() => (showModeration = !showModeration)}>MODERATION</button><button class="quick-action" onclick={() => (showCollaboration = !showCollaboration)}>COLLABORATION</button><button class="quick-action">OVERLAYS</button></div>{#if showCollaboration}<div class="stream-editor collaboration-panel"><div class="micro">GUEST STAR SESSION</div>{#if collaborationSession}<div class="collaboration-status">ACTIVE · {collaborationSession.guests.length} GUESTS</div><button class="primary control-button" onclick={endCollaboration} disabled={collaborationBusy}>END SESSION</button><label class="micro" for="collaboration-guest">GUEST TWITCH ID</label><div class="chat-send-row"><input id="collaboration-guest" class="control" bind:value={collaborationGuestId} maxlength="64" placeholder="Twitch user ID" /><button class="primary" onclick={sendCollaborationInvite} disabled={collaborationBusy || !collaborationGuestId.trim()}>INVITE</button></div>{#if collaborationInvites.length}<div class="micro">PENDING INVITES</div>{#each collaborationInvites as invite}<div class="collaboration-invite"><span>{invite.user_id} · {invite.status}</span><button class="secondary-action" onclick={() => cancelCollaborationInvite(invite.user_id)} disabled={collaborationBusy}>CANCEL</button></div>{/each}{/if}{:else}<div class="empty-row">No active collaboration.</div><button class="primary control-button" onclick={createCollaboration} disabled={collaborationBusy}>START GUEST STAR</button>{/if}{#if collaborationError}<div class="auth-error">{collaborationError}</div>{/if}</div>{/if}{#if showStreamEditor}<div class="stream-editor"><label class="micro" for="stream-title">TITLE</label><input id="stream-title" class="control" bind:value={streamTitle} maxlength="140" /><label class="micro" for="stream-category">CATEGORY</label><input id="stream-category" class="control" value={categorySearch} oninput={(event) => searchCategories(event.currentTarget.value)} autocomplete="off" placeholder="Search Twitch categories" />{#if categoryOptions.length}<div class="category-options">{#each categoryOptions as category}<button class="category-option" onclick={() => { streamGameId = category.id; categorySearch = category.name; categoryOptions = []; }}><span>{category.name}</span><span class="mono">{category.id}</span></button>{/each}</div>{/if}<label class="micro" for="stream-language">LANGUAGE</label><input id="stream-language" class="control" bind:value={streamLanguage} maxlength="10" /><label class="micro" for="stream-tags">TAGS</label><input id="stream-tags" class="control" bind:value={streamTags} placeholder="comma, separated" /><label class="micro" for="stream-labels">CLASSIFICATION LABELS</label><input id="stream-labels" class="control" bind:value={streamClassificationLabels} placeholder="comma, separated" /><button class="primary control-button" onclick={saveStreamInfo} disabled={streamBusy}>SAVE STREAM INFO</button>{#if streamError}<div class="auth-error">{streamError}</div>{/if}</div>{/if}{#if showModeration}<div class="stream-editor"><label class="micro" for="moderation-action">ACTION</label><select id="moderation-action" class="control" bind:value={moderationAction}><option value="timeout">TIMEOUT</option><option value="ban">BAN</option><option value="unban">UNBAN</option><option value="delete_message">DELETE MESSAGE</option></select><label class="micro" for="moderation-user">USER ID</label><input id="moderation-user" class="control" bind:value={moderationUserId} maxlength="64" /><label class="micro" for="moderation-message">MESSAGE ID (DELETE)</label><input id="moderation-message" class="control" bind:value={moderationMessageId} maxlength="128" /><label class="micro" for="moderation-duration">TIMEOUT SECONDS</label><input id="moderation-duration" class="control" type="number" min="1" max="1209600" bind:value={moderationDuration} /><label class="micro" for="moderation-reason">REASON</label><input id="moderation-reason" class="control" maxlength="500" bind:value={moderationReason} /><button class="primary control-button" onclick={applyModeration} disabled={moderationBusy || !moderationUserId}>APPLY MODERATION</button>{#if moderationError}<div class="auth-error">{moderationError}</div>{/if}</div>{/if}</section>
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
{#if authenticated && showCollaboration}
|
||
<section class="panel collaboration-search-panel">
|
||
<div class="panel-header"><span>COLLABORATOR SEARCH</span><span class="panel-menu">⋮</span></div>
|
||
<div class="stream-editor">
|
||
<label class="micro" for="collaborator-search">EXACT TWITCH LOGIN</label>
|
||
<div class="chat-send-row">
|
||
<input id="collaborator-search" 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 || !collaborationSession}>INVITE</button></div>
|
||
{/each}
|
||
</div>
|
||
{:else if collaboratorSearch && !collaboratorSearchBusy && !collaboratorSearchError}<div class="empty-row">No exact Twitch user found.</div>{/if}
|
||
<div class="micro">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><button class="primary" onclick={() => inviteCollaborator({ id: collaborator.twitch_user_id, display_name: collaborator.display_name })} disabled={collaborationBusy || !collaborationSession}>INVITE</button></div>
|
||
{/each}
|
||
</div>
|
||
{:else}<div class="empty-row">No saved collaborators yet.</div>{/if}
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
{#if chatMessages.some((message) => message.reply_parent_message_id)}
|
||
<section class="panel thread-index-panel">
|
||
<div class="panel-header"><span>CHAT THREADS</span></div>
|
||
{#each chatMessages.filter((message) => message.reply_parent_message_id) as reply}
|
||
{@const root = chatMessages.find((message) => message.message_id === reply.reply_parent_message_id)}
|
||
{#if root}<button class="thread-index-row" onclick={() => openChatThread(root)}><span>{root.user_name}: {root.text}</span><span class="micro">OPEN THREAD</span></button>{/if}
|
||
{/each}
|
||
</section>
|
||
{/if}
|
||
{#if chatThreadRoot}
|
||
<section class="panel chat-thread-panel">
|
||
<div class="panel-header"><span>THREAD · @{chatThreadRoot.user_name}</span><button class="secondary" onclick={() => (chatThreadRoot = undefined)}>CLOSE</button></div>
|
||
<div class="thread-root"><strong>{chatThreadRoot.user_name}</strong><span>{chatThreadRoot.text}</span></div>
|
||
<div class="thread-replies">
|
||
{#each chatMessages.filter((message) => message.reply_parent_message_id === chatThreadRoot?.message_id) as reply}
|
||
<div class="thread-reply"><strong>{reply.user_name}</strong><span>{reply.text}</span></div>
|
||
{:else}<div class="empty-row">No replies received yet.</div>{/each}
|
||
</div>
|
||
<button class="primary" onclick={() => chatThreadRoot && replyToChatMessage(chatThreadRoot)}>REPLY IN THREAD</button>
|
||
</section>
|
||
{/if}
|
||
{#if pendingOverlayDraft}
|
||
<section class="panel overlay-recovery-panel">
|
||
<div class="panel-header"><span>LOCAL OVERLAY DRAFT FOUND</span></div>
|
||
<div class="settings-body"><span>A newer unsaved draft is available for this overlay.</span><div class="overlay-controls"><button class="primary" onclick={restorePendingOverlayDraft}>RESTORE LOCAL DRAFT</button><button class="secondary" onclick={discardPendingOverlayDraft}>DISCARD LOCAL DRAFT</button></div></div>
|
||
</section>
|
||
{/if}
|
||
{#if activeView === 'Overlays' && selectedOverlayId}
|
||
<section class="panel overlay-danger-panel"><div class="panel-header"><span>OVERLAY LIFECYCLE</span></div><div class="settings-body"><span class="micro">DELETION IS PERMANENT AND INVALIDATES THE BROWSER-SOURCE URL</span><button class="danger" onclick={deleteOverlay} disabled={overlayBusy}>DELETE OVERLAY</button></div></section>
|
||
{/if}
|
||
{#if activeView === 'Overlays' && (selectedOverlayId || overlayName) && overlayVariableWarnings().length}
|
||
<section class="panel overlay-diagnostics-panel">
|
||
<div class="panel-header"><button class="diagnostics-toggle" onclick={() => (overlayDiagnosticsOpen = !overlayDiagnosticsOpen)} aria-expanded={overlayDiagnosticsOpen}>⚠ {overlayVariableWarnings().length} VARIABLE WARNING{overlayVariableWarnings().length === 1 ? '' : 'S'}</button></div>
|
||
{#if overlayDiagnosticsOpen}<div class="diagnostics-details">{#each overlayVariableWarnings() as warning}<div>{warning}</div>{/each}</div>{/if}
|
||
</section>
|
||
{/if}
|
||
{#if !authenticated && publicBotCommands.length}
|
||
<section class="panel public-command-catalog">
|
||
<div class="panel-header"><span>PUBLIC BOT COMMANDS</span></div>
|
||
<div class="settings-body">
|
||
<div class="micro">AVAILABLE COMMANDS</div>
|
||
<div class="bot-command-list">
|
||
{#each publicBotCommands 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></div></article>
|
||
{/each}
|
||
</div>
|
||
</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>
|