Files
trixy-linux-client/src/App.svelte
T
ReaperOfVeriod 1043d4057b
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
Add authenticated backup restore control
2026-10-25 05:54:58 +01:00

1350 lines
86 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
type ClientSettings = { server_address: string; username: string };
function nativeClientAvailable() {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
async function saveNativeSession() {
if (!nativeClientAvailable() || !username || !serverAddress || !sessionToken) return;
const settings: ClientSettings = { server_address: 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 = 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 loadOverlays();
await loadBackups();
await loadStreamInfo();
connectRealtime();
}
async function restoreNativeSession() {
if (!nativeClientAvailable() || !username || !serverAddress) return;
try {
const settings: ClientSettings = { server_address: serverAddress, username };
const storedSession = await invoke<string | null>('load_server_session', { settings });
if (!storedSession) return;
const response = await fetch(`${serverAddress}/api/v1/auth/session`, {
headers: { authorization: `Bearer ${storedSession}` },
});
if (!response.ok) {
await invoke('clear_server_session', { settings });
return;
}
const result: { must_replace_credentials: boolean } = await response.json();
sessionToken = storedSession;
authenticated = true;
mustReplaceCredentials = result.must_replace_credentials;
connectionState = 'CONNECTED';
await loadAuthenticatedData();
if (mustReplaceCredentials) newUsername = username;
} catch {
// A stale keyring session must not prevent the normal login form from opening.
sessionToken = '';
authenticated = false;
}
}
let serverAddress = 'http://127.0.0.1:8787';
let connectionState = 'OFFLINE';
let serverVersion = '—';
let serverCompatibility = '';
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 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; slot_id?: string | null; is_live?: boolean | null }> } | null = null;
let collaborationInvites: Array<{ user_id: string; invited_at: string; status: string }> = [];
let collaborationBusy = false;
let collaborationError = '';
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 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 oauthLink = '';
let oauthIdentity: 'streamer' | 'bot' = 'streamer';
let oauthBusy = false;
let oauthError = '';
let botCommands: Array<{ id: string; name: string; description: string; permission_level: string; aliases: string[]; tags: string[]; cooldown_seconds: number; cooldown_scope: string; response_template: string; error_template: string | null; enabled: boolean }> = [];
let 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 commandFormBusy = false;
let commandVariableSuggestions: string[] = [];
let botSecrets: Array<{ id: string; name: string; description: string; used_by: string[] }> = [];
let secretName = '';
let secretDescription = '';
let secretValue = '';
let secretError = '';
let overlays: Array<{ id: string; name: string; slug: string; draft_html: string; draft_css: string; draft_javascript: string; published: boolean; enabled: boolean; disabled_design: string }> = [];
let selectedOverlayId = '';
let overlayName = '';
let overlaySlug = '';
let overlayHtml = '';
let overlayCss = '';
let overlayJavascript = '';
let overlayEnabled = true;
let overlayDisabledDesign = 'transparent';
let overlayError = '';
let overlayBusy = false;
let overlaySavedSignature = '';
let overlayAutosaveAttemptedSignature = '';
let pendingOverlayDraft: { overlayName: string; overlaySlug: string; overlayHtml: string; overlayCss: string; overlayJavascript: string; overlayEnabled: boolean; overlayDisabledDesign: string } | null = null;
let overlayDiagnosticsOpen = false;
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 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() {
if (connectionCheckRunning) return;
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';
}
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 login() {
if (connectionState === 'INCOMPATIBLE') {
authError = 'This client version is not compatible with the server.';
return;
}
authBusy = true;
authError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) throw new Error('Your username or password may be incorrect.');
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';
await loadAuthenticatedData();
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() {
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 = [];
}
function connectRealtime() {
realtimeSocket?.close();
const socketUrl = serverAddress.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 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;
collaborationSettings = 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();
await loadCollaboration();
} catch (error) {
collaborationError = error instanceof Error ? error.message : 'Collaboration session failed.';
} finally {
collaborationBusy = false;
}
}
async function endCollaboration() {
if (!collaborationSession) 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 = [];
} 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 = '';
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;
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.');
await loadCollaboration();
} catch (error) {
collaborationError = error instanceof Error ? error.message : 'Invite could not be cancelled.';
} 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 = [];
commandFormEnabled = true;
}
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 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;
}
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);
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 }),
});
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 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 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;
overlaySavedSignature = overlayDraftSignature();
overlayAutosaveAttemptedSignature = overlaySavedSignature;
restoreOverlayDraft(overlay.id);
}
function overlayDraftSignature() {
return JSON.stringify({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign });
}
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 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 }));
} 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 }> | 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,
};
} 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';
}
function restorePendingOverlayDraft() {
if (!pendingOverlayDraft) return;
({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign } = 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 });
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', '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 === '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><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>
{: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 collaborationError}<div class="auth-error">{collaborationError}</div>{/if}
</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 &amp; 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 collaborationError}<div class="auth-error">{collaborationError}</div>{/if}
</section>
{/if}
{:else if activeView === 'Overlays'}
<section class="overlay-page panel"><div class="panel-header"><span>OVERLAYS</span><div class="overlay-toolbar"><button class="secondary" onclick={createOverlay}>NEW</button><button class="secondary" onclick={loadOverlays}>REFRESH</button></div></div><div class="overlay-layout"><aside class="overlay-list">{#each overlays as overlay}<button class:active={selectedOverlayId === overlay.id} class="overlay-list-item" onclick={() => selectOverlay(overlay)}><strong>{overlay.name}</strong><span>{overlay.published ? 'PUBLISHED' : 'DRAFT'}</span></button>{/each}{#if !overlays.length}<div class="empty-row">No overlays yet.</div>{/if}</aside><div class="overlay-editor">{#if selectedOverlayId || overlayName}<div class="overlay-fields"><input class="control" bind:value={overlayName} maxlength="100" placeholder="Overlay name" /><input class="control" bind:value={overlaySlug} maxlength="100" placeholder="stable-slug" /><label class="overlay-enabled"><input type="checkbox" bind:checked={overlayEnabled} /> ENABLED</label><input class="control" bind:value={overlayDisabledDesign} maxlength="10000" placeholder="Disabled design" /></div><div class="overlay-tabs"><label class="micro" for="overlay-html">HTML</label><textarea id="overlay-html" class="overlay-code" bind:value={overlayHtml} spellcheck="false"></textarea><label class="micro" for="overlay-css">CSS</label><textarea id="overlay-css" class="overlay-code" bind:value={overlayCss} spellcheck="false"></textarea><label class="micro" for="overlay-javascript">JAVASCRIPT</label><textarea id="overlay-javascript" class="overlay-code" bind:value={overlayJavascript} spellcheck="false"></textarea></div><div class="overlay-preview-label micro">LIVE PREVIEW</div><iframe class="overlay-preview" title="Overlay preview" sandbox="allow-scripts" srcdoc={`<style>${overlayCss}</style>${overlayEnabled ? overlayHtml : `<div>${overlayDisabledDesign}</div>`}\u003cscript\u003e${overlayJavascript}\u003c/script\u003e`}></iframe><div class="overlay-controls"><button class="primary" onclick={saveOverlay} disabled={overlayBusy || !overlayName || !overlaySlug}>SAVE DRAFT</button><button class="secondary" onclick={publishOverlay} disabled={overlayBusy || !selectedOverlayId}>PUBLISH</button><button class="secondary" onclick={undoOverlay} disabled={overlayBusy || !selectedOverlayId}>UNDO PUBLISHED</button><button class="secondary" onclick={copyOverlayUrl} disabled={!overlaySlug}>COPY OBS URL</button></div>{#if overlayError}<div class="auth-error">{overlayError}</div>{/if}{:else}<div class="empty-state"><strong>Select or create an overlay</strong><span>Drafts autosave when explicitly saved; publishing controls the browser source.</span></div>{/if}</div></div></section>
{: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 &amp; 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}
</main>
</div>