641 lines
38 KiB
Svelte
641 lines
38 KiB
Svelte
<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>
|