Files
trixy-linux-client/src/App.svelte
T
2026-10-25 05:06:24 +01:00

641 lines
38 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">
let serverAddress = 'http://127.0.0.1:8787';
let connectionState = 'OFFLINE';
let serverVersion = '—';
let diagnostics: { status: string; database: string; twitch_identity_count: number; unseen_notification_count: number; realtime: string } | undefined;
let activeView = 'Live dashboard';
let sessionToken = '';
let authenticated = false;
let mustReplaceCredentials = false;
let username = '';
let password = '';
let newUsername = '';
let newPassword = '';
let authError = '';
let authBusy = false;
let connectionNotice = '';
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 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 sharedChatSession: { session_id: string; host_broadcaster_id: string; participants: Array<{ broadcaster_id: string }> } | null = null;
let oauthLink = '';
let oauthIdentity: 'streamer' | 'bot' = 'streamer';
let oauthBusy = false;
let oauthError = '';
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 },
};
async function checkServer() {
connectionState = 'CONNECTING';
const retryDelays = [0, 3000, 10000, 15000, 30000];
for (let attempt = 0; attempt < retryDelays.length; attempt += 1) {
if (retryDelays[attempt] > 0) {
connectionState = 'RECONNECTING';
connectionNotice = `Retry ${attempt + 1}/${retryDelays.length} in ${retryDelays[attempt] / 1000}s`;
await new Promise((resolve) => window.setTimeout(resolve, retryDelays[attempt]));
} 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 } = await response.json();
serverVersion = info.server_version;
connectionState = 'CONNECTED';
connectionNotice = '';
return;
} catch {
connectionNotice = 'Server unavailable';
}
}
connectionState = 'SERVER UNAVAILABLE';
connectionNotice = 'Reconnect attempts exhausted. Test connection to try again.';
}
async function login() {
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;
connectionState = 'CONNECTED';
await loadActivity();
await loadNotifications();
await loadDiagnostics();
await loadOAuthStatus();
await loadCollaboration();
await loadStreamInfo();
connectRealtime();
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 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;
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);
}
sessionToken = '';
authenticated = false;
mustReplaceCredentials = false;
oauthLink = '';
notifications = [];
showNotifications = false;
diagnostics = undefined;
chatMessages = [];
collaborationSession = null;
collaborationInvites = [];
showCollaboration = false;
chatReplyParent = 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 } }> } } };
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 }, ...chatMessages].slice(0, 100);
}
}
} 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();
} catch {
collaborationSession = null;
sharedChatSession = null;
}
}
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 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 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.';
});
}
</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', 'Overlays'] 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}
<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>
</main>
</div>