beeta/frontend/src/lib/components/chat/ChatPanel.svelte
doomtube 2e376269c2
Some checks failed
Build and Push / build-all (push) Failing after 2m22s
fixes lol
2026-01-09 21:27:30 -05:00

1437 lines
37 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>
import { onMount, onDestroy } from 'svelte';
import { get } from 'svelte/store';
import {
filteredMessages,
connectionStatus,
chatUserInfo,
participants,
messageVisibilityFilter,
hiddenUsers,
loadVisibilitySettings,
selectedRealms,
availableRealms,
toggleRealmFilter,
resetToGlobal,
fetchRealmStats
} from '$lib/chat/chatStore';
import { chatWebSocket } from '$lib/chat/chatWebSocket';
import { chatLayout } from '$lib/stores/chatLayout';
import { auth, isAuthenticated } from '$lib/stores/auth';
import {
ttsEnabled,
ttsSettings,
ttsUserFilter,
availableVoices,
initVoices,
loadSettings as loadTtsSettings,
setVoice
} from '$lib/chat/ttsStore';
import ChatMessage from './ChatMessage.svelte';
import ChatInput from './ChatInput.svelte';
import ProfilePreview from './ProfilePreview.svelte';
export let realmId;
export let userColor = '#FFFFFF';
export let chatEnabled = true;
export let chatGuestsAllowed = true;
export let hideHeader = false;
export let hideTheaterMode = false;
let messagesContainer;
let autoScroll = true;
let initialScrollDone = false; // Prevent handleScroll from disabling autoScroll before initial scroll
let showMenu = false; // Combined settings + participants menu
let configCollapsed = false; // Track if config section is collapsed
let usersCollapsed = false; // Track if users section is collapsed
let realmsCollapsed = false; // Track if realms section is collapsed
let participantModMenuId = null; // Track which participant's mod menu is open
let modMenuPosition = { x: 0, y: 0 }; // Position for fixed mod menu
let showRenameModal = false;
let newGuestName = '';
let hasNewMessage = false;
let fadeTimeout;
let activeProfilePreview = null;
let chatInputRef;
let honkAudio = null;
let honkSoundUrl = null;
let mentionedMessageIds = new Set(); // Track which messages have already played honk
let wasAuthenticated = false; // Track previous auth state for reconnect detection
$: isConnected = $connectionStatus === 'connected';
// Auto-load participants when connected
$: if (isConnected) {
chatWebSocket.getParticipants();
}
// Reconnect WebSocket when user logs in or registers while already connected as guest
$: {
const nowAuthenticated = $isAuthenticated;
if (nowAuthenticated && !wasAuthenticated && isConnected) {
console.log('[ChatPanel] Auth state changed to authenticated, reconnecting...');
chatWebSocket.manualReconnect();
}
wasAuthenticated = nowAuthenticated;
}
function toggleMenu() {
showMenu = !showMenu;
if (showMenu) {
chatWebSocket.getParticipants();
fetchRealmStats(); // Fetch realm stats for the REALMS section
}
}
function toggleParticipantModMenu(participantId, event) {
event.stopPropagation();
if (participantModMenuId === participantId) {
participantModMenuId = null;
} else {
const rect = event.target.getBoundingClientRect();
modMenuPosition = { x: rect.right, y: rect.bottom + 2 };
participantModMenuId = participantId;
}
}
async function handleParticipantModAction(participant, action) {
switch (action) {
case 'kick':
chatWebSocket.kickUser(participant.userId, 60, 'Kicked by moderator');
break;
case 'mute':
chatWebSocket.muteUser(participant.userId, 0, 'Muted by moderator'); // 0 = permanent
break;
case 'ban':
chatWebSocket.banUser(participant.userId, 'Banned by moderator');
break;
case 'promote':
await promoteToModerator(participant.userId);
break;
case 'demote':
await demoteFromModerator(participant.userId);
break;
}
participantModMenuId = null;
}
async function promoteToModerator(userId) {
if (!userId) return;
try {
const response = await fetch(`/api/admin/users/${userId}/promote-moderator`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
chatWebSocket.getParticipants(); // Refresh participants list
}
} catch (e) {
console.error('Failed to promote user:', e);
}
}
async function demoteFromModerator(userId) {
if (!userId) return;
try {
const response = await fetch(`/api/admin/users/${userId}/demote-moderator`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
chatWebSocket.getParticipants(); // Refresh participants list
}
} catch (e) {
console.error('Failed to demote user:', e);
}
}
// Check if current user has mod powers
$: hasModPowers = $chatUserInfo.isModerator || $chatUserInfo.isAdmin || $chatUserInfo.isStreamer;
let chatLoadedAt = Date.now();
async function loadActiveHonkSound() {
try {
const response = await fetch('/api/honk/active');
if (response.ok) {
const data = await response.json();
if (data.honk && data.honk.filePath) {
honkSoundUrl = data.honk.filePath;
honkAudio = new Audio(honkSoundUrl);
honkAudio.volume = 0.5;
honkAudio.load();
}
}
} catch (e) {
// Silently fail - honk is non-essential
}
}
function handleMentioned(event) {
const { messageId, timestamp } = event.detail;
// Only play honk once per message
if (mentionedMessageIds.has(messageId)) return;
mentionedMessageIds.add(messageId);
// Only play honk for messages that arrived after chat loaded (not historical messages)
const messageTime = timestamp ? new Date(timestamp).getTime() : Date.now();
if (messageTime < chatLoadedAt - 5000) return;
if (honkSoundUrl) {
const audio = new Audio(honkSoundUrl);
audio.volume = 0.5;
audio.play().catch(() => {});
}
}
onMount(() => {
// Initialize TTS voices and load realm settings
initVoices();
loadTtsSettings(realmId);
loadVisibilitySettings(realmId);
// Load active honk sound for mentions
loadActiveHonkSound();
// Only connect if chat is enabled
if (!chatEnabled) {
return;
}
// Connect to chat - fetch fresh token if authenticated (uses httpOnly cookies)
(async () => {
let token = null;
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.token) {
token = data.token;
}
}
} catch (e) {
// Not authenticated or refresh failed - connect as guest
}
console.log('Chat connecting with realmId:', realmId, token ? '(authenticated)' : '(guest)');
chatWebSocket.connect(realmId, token);
})();
// Function to scroll to newest messages (bottom for UP flow, top for DOWN flow)
const scrollToBottom = () => {
if (autoScroll && messagesContainer) {
requestAnimationFrame(() => {
const messagesFromTop = get(chatLayout).messagesFromTop;
if (messagesFromTop) {
// column-reverse: newest at top, scroll to top
messagesContainer.scrollTop = 0;
} else {
// normal: newest at bottom, scroll to bottom
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
initialScrollDone = true;
});
}
};
// Scroll to bottom when new messages arrive
const unsubscribe = filteredMessages.subscribe((msgs) => {
scrollToBottom();
// Trigger new message indicator for theater mode
if ($chatLayout.theaterMode && msgs.length > 0) {
hasNewMessage = true;
clearTimeout(fadeTimeout);
fadeTimeout = setTimeout(() => {
hasNewMessage = false;
}, 5000);
}
});
// Watch for content size changes (e.g., when images load)
// This ensures autoscroll works even when images finish loading after message is added
let resizeObserver;
if (messagesContainer) {
resizeObserver = new ResizeObserver(() => {
scrollToBottom();
});
resizeObserver.observe(messagesContainer);
}
return () => {
unsubscribe();
if (resizeObserver) {
resizeObserver.disconnect();
}
};
});
onDestroy(() => {
// Keep connection alive for terminal usage
// chatWebSocket.disconnect();
if (fadeTimeout) clearTimeout(fadeTimeout);
});
function handleSendMessage(event) {
let { message, selfDestructSeconds } = event.detail;
// Handle /graffiti command
if (message.trim().toLowerCase() === '/graffiti') {
const graffitiUrl = $auth.user?.graffitiUrl;
if (graffitiUrl) {
message = `[graffiti]${graffitiUrl}[/graffiti]`;
} else {
alert('You don\'t have a graffiti yet. Create one in Settings > Appearance.');
return;
}
}
chatWebSocket.sendMessage(message, userColor, selfDestructSeconds || 0);
}
function handleScroll() {
// Ignore scroll events before initial scroll is done (prevents browser's initial position from disabling autoScroll)
if (!initialScrollDone) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
if ($chatLayout.messagesFromTop) {
// column-reverse: user at "newest" means scrollTop near 0
autoScroll = scrollTop <= 50;
} else {
// normal: user at bottom means scrollTop + clientHeight near scrollHeight
autoScroll = scrollTop + clientHeight >= scrollHeight - 50;
}
}
function handleDeleteMessage(messageId) {
chatWebSocket.deleteMessage(messageId);
}
function handleModAction(event) {
const { action, userId, duration, reason } = event.detail;
switch (action) {
case 'ban':
chatWebSocket.banUser(userId, reason);
break;
case 'mute':
chatWebSocket.muteUser(userId, duration || 0, reason); // 0 = permanent by default
break;
}
}
function handleRename() {
if (!newGuestName || newGuestName.trim().length === 0) {
alert('Please enter a name');
return;
}
// Send rename message to server
// Note: State updates (localStorage + store) happen in rename_success handler
// to avoid updating UI before server confirms the rename
if (chatWebSocket.sendRename(newGuestName.trim())) {
showRenameModal = false;
newGuestName = '';
} else {
alert('Failed to send rename request');
}
}
function handleClickOutside(event) {
// Close combined menu when clicking outside
if (showMenu) {
const menuContainer = event.target.closest('.menu-container');
const combinedMenu = event.target.closest('.combined-menu');
if (!menuContainer && !combinedMenu) {
showMenu = false;
participantModMenuId = null;
}
}
// Close participant mod menu when clicking outside
if (participantModMenuId !== null) {
const modContainer = event.target.closest('.participant-mod-container');
if (!modContainer) {
participantModMenuId = null;
}
}
}
function handleCopySticker(event) {
const { stickerName } = event.detail;
if (chatInputRef) {
chatInputRef.insertText(stickerName);
}
}
function handleMention(event) {
const { username } = event.detail;
if (chatInputRef) {
chatInputRef.insertText(`@${username} `);
}
}
function handleShowProfile(event) {
const { username, userId, isGuest, messageId, position } = event.detail;
activeProfilePreview = {
username,
userId,
isGuest,
messageId,
position
};
}
function handleProfileClose() {
activeProfilePreview = null;
}
function popoutChat() {
const realmParam = realmId ? `?realm=${realmId}` : '';
const popoutUrl = `/chat/popout${realmParam}`;
window.open(
popoutUrl,
'ChatPopout',
'width=400,height=700,menubar=no,toolbar=no,location=no,status=no'
);
}
// Check if currently showing global (all realms)
$: isGlobalMode = $selectedRealms.size === 0;
</script>
<svelte:window on:click={handleClickOutside} />
<div
class="chat-panel"
class:inverted={$chatLayout.inverted}
class:messages-from-top={$chatLayout.messagesFromTop}
class:theater-mode={$chatLayout.theaterMode && !hideTheaterMode}
class:has-new-message={hasNewMessage}
on:mouseenter={() => { if ($chatLayout.theaterMode && !hideTheaterMode) hasNewMessage = true; }}
on:mouseleave={() => { if ($chatLayout.theaterMode && !hideTheaterMode) { clearTimeout(fadeTimeout); fadeTimeout = setTimeout(() => hasNewMessage = false, 3000); } }}
>
{#if showRenameModal}
<div class="modal-overlay" on:click={() => showRenameModal = false}>
<div class="modal-content small" on:click|stopPropagation>
<div class="modal-header">
<h3>Change Display Name</h3>
<button class="close-btn" on:click={() => showRenameModal = false}>×</button>
</div>
<div class="rename-form">
<p style="color: #999; font-size: 0.9rem; margin-bottom: 1rem;">
Current name: <span style="color: {userColor}; font-weight: 600;">{$chatUserInfo.username}</span>
</p>
<form on:submit|preventDefault={handleRename}>
<input
type="text"
bind:value={newGuestName}
placeholder="Enter new name..."
maxlength="30"
pattern="[a-zA-Z0-9_-]+"
title="Letters, numbers, underscores, and hyphens only"
style="width: 100%; padding: 0.75rem; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; color: #fff; font-size: 1rem; margin-bottom: 1rem;"
required
/>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn-primary" style="flex: 1; padding: 0.75rem; background: #4a9eff; border: none; border-radius: 4px; color: #fff; font-weight: 600; cursor: pointer;">
Change Name
</button>
<button type="button" class="btn-secondary" on:click={() => showRenameModal = false} style="flex: 1; padding: 0.75rem; background: #444; border: none; border-radius: 4px; color: #fff; font-weight: 600; cursor: pointer;">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
{/if}
{#if !hideHeader}
<div class="chat-header" class:menu-open={showMenu}>
<h3 class="realm-title">{realmId || 'Global'}</h3>
<div class="header-actions">
{#if chatEnabled && isConnected && $chatUserInfo.isGuest}
<button class="header-btn" on:click={() => showRenameModal = true} title="Change Display Name">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
</button>
{/if}
<!-- Popout button -->
<button class="header-btn" on:click={popoutChat} title="Pop out chat">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M6.5 1A.5.5 0 0 1 7 .5h7a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0V1.5H7a.5.5 0 0 1-.5-.5z"/>
<path d="M13.5 1l-6 6H4a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V9.5l6-6z"/>
</svg>
</button>
<!-- Combined Menu button (settings + participants) -->
{#if chatEnabled}
<div class="menu-container">
<button class="header-btn menu-btn" on:click={toggleMenu} title="Menu">
<span class="status-dot" class:connected={isConnected} class:disconnected={!isConnected}></span>
<span class="btn-count">{$participants.length}</span>
</button>
</div>
{/if}
</div>
</div>
<!-- Combined menu - positioned relative to chat-panel, outside chat-header -->
{#if showMenu}
<div class="combined-menu" on:click|stopPropagation>
<!-- Settings Section (Collapsible) -->
<div class="menu-section">
<button class="menu-section-header collapsible" on:click={() => configCollapsed = !configCollapsed}>
<span class="collapse-arrow" class:collapsed={configCollapsed}>▼</span>
-- CONFIG --
</button>
{#if !configCollapsed}
<div class="config-columns">
<!-- Left column: Layout options -->
<div class="config-column">
<button class="menu-option" on:click={() => chatLayout.togglePosition()}>
<span class="option-marker">></span>
<span class="option-label">position:</span>
<span class="option-value">{$chatLayout.position.toUpperCase()}</span>
</button>
<button class="menu-option" on:click={() => chatLayout.toggleInverted()}>
<span class="option-marker">></span>
<span class="option-label">input:</span>
<span class="option-value">{$chatLayout.inverted ? 'TOP' : 'BOTTOM'}</span>
</button>
<button class="menu-option" on:click={() => chatLayout.toggleMessagesFromTop()}>
<span class="option-marker">></span>
<span class="option-label">flow:</span>
<span class="option-value">{$chatLayout.messagesFromTop ? 'DOWN' : 'UP'}</span>
</button>
{#if !hideTheaterMode}
<button class="menu-option" on:click={() => chatLayout.toggleTheaterMode()}>
<span class="option-marker">></span>
<span class="option-label">theater:</span>
<span class="option-value" class:active-on={$chatLayout.theaterMode}>{$chatLayout.theaterMode ? 'ON' : 'OFF'}</span>
</button>
{/if}
<div class="menu-option-row">
<span class="option-marker">></span>
<span class="option-label">show:</span>
<select class="inline-select" value={$messageVisibilityFilter} on:change={(e) => messageVisibilityFilter.set(e.target.value)}>
<option value="all">ALL</option>
<option value="registered">REG</option>
<option value="guests">GUEST</option>
</select>
</div>
</div>
<!-- Right column: TTS options -->
<div class="config-column tts-column">
<button class="menu-option" on:click={() => ttsEnabled.update(v => !v)}>
<span class="option-marker">></span>
<span class="option-label">tts:</span>
<span class="option-value" class:active-on={$ttsEnabled}>{$ttsEnabled ? 'ON' : 'OFF'}</span>
</button>
<div class="menu-option-row">
<span class="option-marker"> </span>
<span class="option-label">voice:</span>
<select class="inline-select" value={$ttsSettings.voiceName} on:change={(e) => setVoice(e.target.value)}>
<option value="">default</option>
{#each $availableVoices as voice}
<option value={voice.name}>{voice.name}</option>
{/each}
</select>
</div>
<div class="menu-option-row">
<span class="option-marker"> </span>
<span class="option-label">speed:</span>
<input type="range" class="inline-range" min="0.1" max="4" step="0.1" value={$ttsSettings.rate} on:input={(e) => ttsSettings.update(s => ({ ...s, rate: parseFloat(e.target.value) }))} />
<span class="range-value">{$ttsSettings.rate.toFixed(1)}</span>
</div>
<div class="menu-option-row">
<span class="option-marker"> </span>
<span class="option-label">pitch:</span>
<input type="range" class="inline-range" min="0" max="2" step="0.1" value={$ttsSettings.pitch} on:input={(e) => ttsSettings.update(s => ({ ...s, pitch: parseFloat(e.target.value) }))} />
<span class="range-value">{$ttsSettings.pitch.toFixed(1)}</span>
</div>
<div class="menu-option-row">
<span class="option-marker"> </span>
<span class="option-label">volume:</span>
<input type="range" class="inline-range" min="0" max="1" step="0.1" value={$ttsSettings.volume} on:input={(e) => ttsSettings.update(s => ({ ...s, volume: parseFloat(e.target.value) }))} />
<span class="range-value">{$ttsSettings.volume.toFixed(1)}</span>
</div>
</div>
</div>
{/if}
</div>
<!-- Users Section (Collapsible) -->
<div class="menu-section">
<button class="menu-section-header collapsible" on:click={() => usersCollapsed = !usersCollapsed}>
<span class="collapse-arrow" class:collapsed={usersCollapsed}>▼</span>
-- USERS ({$participants.length}) --
</button>
{#if !usersCollapsed}
<div class="users-list">
{#each $participants as participant (participant.userId)}
<div class="user-row">
<span class="user-prefix">{participant.isStreamer ? '*' : participant.isModerator ? '+' : ' '}</span>
<button
class="user-name-btn"
class:guest={participant.isGuest}
style={participant.userColor && !participant.isGuest ? `color: ${participant.userColor}` : ''}
on:click={(e) => {
// Toggle: close if same user, open if different
if (activeProfilePreview && activeProfilePreview.userId === participant.userId) {
activeProfilePreview = null;
} else {
const rect = e.target.getBoundingClientRect();
activeProfilePreview = {
username: participant.username,
userId: participant.userId,
isGuest: participant.isGuest,
position: { x: rect.left, y: rect.bottom + 4 }
};
}
}}
on:auxclick={(e) => {
// Middle-click opens profile in new tab
if (e.button === 1 && !participant.isGuest) {
e.preventDefault();
window.open(`/profile/${participant.username}`, '_blank');
}
}}
>{participant.username}</button>
{#if hasModPowers && participant.userId !== $chatUserInfo.userId && !participant.isStreamer && ($chatUserInfo.isAdmin || !participant.isModerator)}
<div class="participant-mod-container">
<button class="user-mod-btn" on:click={(e) => toggleParticipantModMenu(participant.userId, e)}>...</button>
{#if participantModMenuId === participant.userId}
<div class="user-mod-menu" style="left: {modMenuPosition.x}px; top: {modMenuPosition.y}px; transform: translateX(-100%);" on:click|stopPropagation>
<button on:click={() => handleParticipantModAction(participant, 'kick')}>kick</button>
<button on:click={() => handleParticipantModAction(participant, 'mute')}>mute</button>
<button class="danger" on:click={() => handleParticipantModAction(participant, 'ban')}>ban</button>
{#if $chatUserInfo.isAdmin && !participant.isGuest}
{#if participant.isModerator}
<button class="warn" on:click={() => handleParticipantModAction(participant, 'demote')}>demote</button>
{:else}
<button class="success" on:click={() => handleParticipantModAction(participant, 'promote')}>promote</button>
{/if}
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/each}
{#if $participants.length === 0}
<div class="empty-users">no users online</div>
{/if}
</div>
{/if}
</div>
<!-- Realms Section (Collapsible) -->
<div class="menu-section">
<button class="menu-section-header collapsible" on:click={() => realmsCollapsed = !realmsCollapsed}>
<span class="collapse-arrow" class:collapsed={realmsCollapsed}>▼</span>
-- REALMS --
</button>
{#if !realmsCollapsed}
<div class="realms-list">
<div class="realm-row" class:active={isGlobalMode}>
<button class="realm-name-btn" on:click={() => resetToGlobal()}>
<span class="realm-marker">{isGlobalMode ? '>' : ' '}</span>
GLOBAL
</button>
</div>
{#each $availableRealms as realm (realm.realmId)}
<div class="realm-row" class:active={$selectedRealms.has(realm.realmId)}>
<button class="realm-name-btn" on:click={() => toggleRealmFilter(realm.realmId)}>
<span class="realm-marker">{$selectedRealms.has(realm.realmId) ? '>' : ' '}</span>
{realm.realmId}
</button>
<span class="realm-count">{realm.participantCount}</span>
</div>
{/each}
{#if $availableRealms.length === 0}
<div class="empty-realms">no realms</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
{/if}
{#if !chatEnabled}
<div class="chat-disabled-message">
<div class="disabled-icon">💬</div>
<p><strong>Chat Disabled</strong></p>
<p class="disabled-text">The streamer has disabled chat for this realm.</p>
</div>
{:else if !chatGuestsAllowed && $chatUserInfo.isGuest}
<div class="chat-disabled-message">
<div class="disabled-icon">🔒</div>
<p><strong>Guests Not Allowed</strong></p>
<p class="disabled-text">This chat requires authentication. Please log in to participate.</p>
<a href="/login" class="btn" style="margin-top: 1rem; padding: 0.75rem 1.5rem; background: var(--primary); color: white; text-decoration: none; border-radius: 4px; display: inline-block;">
Login
</a>
</div>
{:else}
<div class="messages-container" bind:this={messagesContainer} on:scroll={handleScroll}>
{#each $filteredMessages as message (message.messageId)}
<ChatMessage
{message}
showHeader={message.showHeader ?? false}
currentUserId={$chatUserInfo.userId}
currentUsername={$chatUserInfo.username}
currentRealmId={realmId}
isModerator={$chatUserInfo.isModerator}
isAdmin={$chatUserInfo.isAdmin}
on:delete={() => handleDeleteMessage(message.messageId)}
on:modAction={handleModAction}
on:showProfile={handleShowProfile}
on:copySticker={handleCopySticker}
on:mentioned={handleMentioned}
on:mention={handleMention}
/>
{/each}
{#if $filteredMessages.length === 0}
<div class="empty-state">
<p>No messages yet. Be the first to chat!</p>
</div>
{/if}
</div>
<ChatInput
bind:this={chatInputRef}
disabled={!isConnected}
username={$chatUserInfo.username}
isGuest={$chatUserInfo.isGuest}
on:send={handleSendMessage}
/>
{/if}
{#if activeProfilePreview}
<ProfilePreview
username={activeProfilePreview.username}
userId={activeProfilePreview.userId}
isGuest={activeProfilePreview.isGuest}
position={activeProfilePreview.position}
on:close={handleProfileClose}
/>
{/if}
</div>
<style>
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
min-height: 0;
background: #1a1a1a;
position: relative;
overflow: hidden; /* Prevent panel itself from scrolling */
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border-bottom: 1px solid #333;
background: #000;
flex-shrink: 0;
position: relative;
}
.chat-header h3 {
margin: 0;
font-size: 1rem;
color: #fff;
}
.realm-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* Unified slim header button style */
.header-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
height: 26px;
min-width: 26px;
padding: 0 0.375rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #aaa;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.15s;
}
.header-btn:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
color: #fff;
}
.header-btn .status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #666;
flex-shrink: 0;
}
.header-btn .status-dot.connected {
background: #0f0;
}
.header-btn .status-dot.disconnected {
background: #f44336;
}
.btn-count {
font-weight: 500;
}
.messages-container {
flex: 1 1 0; /* Grow, shrink, base 0 */
background: #000;
overflow-y: auto;
overflow-x: hidden;
padding: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
min-height: 0; /* Allow flex shrinking */
max-height: 100%; /* Don't exceed container */
width: 100%; /* Ensure full width */
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-style: italic;
}
.chat-disabled-message {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: #999;
}
.disabled-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.chat-disabled-message p {
margin: 0.5rem 0;
}
.chat-disabled-message strong {
color: #fff;
font-size: 1.1rem;
}
.disabled-text {
font-size: 0.9rem;
color: #666;
}
/* Combined Menu (Terminal Style) */
.menu-container {
position: static;
}
.combined-menu {
position: absolute;
left: 0;
right: 0;
top: 38px; /* Height of header */
background: #0a0a0a;
border-bottom: 1px solid #333;
max-height: 50%;
overflow-y: auto;
overflow-x: hidden;
z-index: 200;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.menu-section {
border-bottom: 1px solid #222;
}
.menu-section:last-child {
border-bottom: none;
}
.menu-section-header {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
color: #888;
font-size: 0.7rem;
letter-spacing: 1px;
background: #0d0d0d;
text-align: center;
border: none;
font-family: inherit;
}
.menu-section-header.collapsible {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.menu-section-header.collapsible:hover {
color: #aaa;
}
.collapse-arrow {
position: absolute;
left: 0.75rem;
font-size: 0.55rem;
transition: transform 0.15s ease;
opacity: 0.6;
}
.collapse-arrow.collapsed {
transform: rotate(-90deg);
}
.config-columns {
display: flex;
gap: 1px;
background: #222;
min-width: 0;
}
.config-column {
flex: 1;
min-width: 0;
background: #0a0a0a;
display: flex;
flex-direction: column;
}
.config-column.tts-column {
border-left: 1px solid #222;
}
.menu-option {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
color: #aaa;
font-family: inherit;
font-size: inherit;
cursor: pointer;
text-align: left;
transition: background 0.1s;
min-width: 0;
overflow: hidden;
}
.menu-option:hover {
background: #1a1a1a;
color: #fff;
}
.menu-option.active {
color: #a855f7;
}
.menu-option-row {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
color: #aaa;
min-width: 0;
overflow: hidden;
}
.option-marker {
display: none;
}
.option-label {
color: #888;
margin-right: 0.5rem;
}
.option-value {
color: #a855f7;
margin-left: auto;
}
.option-value.active-on {
color: #f00;
}
.option-count {
color: #666;
margin-left: auto;
font-size: 0.65rem;
}
.inline-select {
background: #111;
border: 1px solid #333;
color: #a855f7;
font-family: inherit;
font-size: inherit;
padding: 0.125rem 0.25rem;
cursor: pointer;
margin-left: auto;
max-width: 164px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.inline-select:hover {
border-color: #555;
}
.inline-range {
width: 60px;
height: 4px;
background: #333;
border: none;
margin-left: auto;
cursor: pointer;
-webkit-appearance: none;
}
.inline-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 8px;
height: 8px;
background: #0f0;
cursor: pointer;
}
.range-value {
width: 24px;
text-align: right;
color: #0f0;
margin-left: 0.5rem;
}
/* Users Grid (3 columns) */
.users-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: #222;
max-height: none;
overflow-y: auto;
}
.user-row {
display: flex;
align-items: center;
padding: 0.35rem 0.5rem;
gap: 0.25rem;
background: #0a0a0a;
min-width: 0; /* Allow text truncation */
}
.user-row:hover {
background: #1a1a1a;
}
.user-prefix {
width: 0.75rem;
color: #ff0;
flex-shrink: 0;
text-align: center;
font-size: 0.7rem;
}
.user-name-btn {
background: none;
border: none;
color: #ccc;
font-family: inherit;
font-size: inherit;
padding: 0;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
text-align: left;
min-width: 0;
}
.user-name-btn:hover {
color: #fff;
text-decoration: underline;
}
.user-name-btn.guest {
color: #666;
font-style: italic;
}
.user-name-btn.guest:hover {
color: #888;
}
.user-mod-btn {
background: #222;
border: 1px solid #333;
color: #888;
font-family: inherit;
font-size: 0.6rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
flex-shrink: 0;
line-height: 1;
}
.user-mod-btn:hover {
background: #333;
color: #fff;
border-color: #444;
}
.user-mod-menu {
position: fixed;
background: #111;
border: 1px solid #444;
z-index: 1000;
display: flex;
flex-direction: column;
min-width: 80px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.user-mod-menu button {
background: none;
border: none;
color: #aaa;
font-family: inherit;
font-size: 0.7rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
text-align: left;
}
.user-mod-menu button:hover {
background: #1a1a1a;
color: #fff;
}
.user-mod-menu button.danger {
color: #f44;
}
.user-mod-menu button.danger:hover {
background: #311;
}
.user-mod-menu button.warn {
color: #fa0;
}
.user-mod-menu button.success {
color: #0f0;
}
.empty-users {
grid-column: 1 / -1; /* Span all columns */
padding: 0.75rem;
color: #555;
text-align: center;
font-style: italic;
background: #0a0a0a;
}
/* Realms Grid (3 columns like users) */
.realms-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: #222;
}
.realm-row {
display: flex;
align-items: center;
padding: 0.35rem 0.5rem;
gap: 0.25rem;
background: #0a0a0a;
min-width: 0;
}
.realm-row:hover {
background: #1a1a1a;
}
.realm-row.active {
background: #1a1a1a;
}
.realm-name-btn {
background: none;
border: none;
color: #888;
font-family: inherit;
font-size: inherit;
padding: 0;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
text-align: left;
min-width: 0;
}
.realm-name-btn:hover {
color: #ccc;
}
.realm-row.active .realm-name-btn {
color: #0f0;
}
.realm-marker {
color: #0f0;
margin-right: 0.25rem;
}
.realm-count {
color: #555;
font-size: 0.65rem;
flex-shrink: 0;
}
.empty-realms {
grid-column: 1 / -1;
padding: 0.75rem;
color: #555;
text-align: center;
font-style: italic;
background: #0a0a0a;
}
.participant-mod-container {
position: relative;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-content {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #333;
}
.modal-header h3 {
margin: 0;
color: #fff;
font-size: 1.1rem;
}
.close-btn {
background: none;
border: none;
color: #999;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.close-btn:hover {
color: #fff;
}
.modal-content.small {
max-width: 300px;
}
/* Inverted layout - input on top, header on bottom */
.chat-panel.inverted .chat-header {
order: 3;
border-bottom: none;
border-top: 1px solid #333;
}
.chat-panel.inverted .messages-container {
order: 2;
}
.chat-panel.inverted :global(.chat-input) {
order: 1;
}
/* Messages from top - newest messages at top */
.chat-panel.messages-from-top .messages-container {
flex-direction: column-reverse;
}
/* Theater mode styles */
.chat-panel.theater-mode {
background: rgba(0, 0, 0, 0.001); /* Nearly invisible but captures hover events */
border: none;
pointer-events: auto; /* Ensure panel receives pointer events */
}
.chat-panel.theater-mode .chat-header {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
border-color: rgba(255, 255, 255, 0.1);
opacity: 0;
transition: opacity 0.3s;
pointer-events: auto;
position: relative;
z-index: 100;
overflow: visible;
}
.chat-panel.theater-mode:hover .chat-header,
.chat-panel.theater-mode.has-new-message .chat-header,
.chat-panel.theater-mode .chat-header.menu-open {
opacity: 1;
}
.chat-panel.theater-mode .chat-header .header-btn {
pointer-events: auto;
}
.chat-panel.theater-mode .menu-container {
pointer-events: auto !important;
position: relative;
z-index: 300;
}
.chat-panel.theater-mode .combined-menu {
pointer-events: auto !important;
z-index: 400;
}
.chat-panel.theater-mode .combined-menu * {
pointer-events: auto !important;
}
.chat-panel.theater-mode .messages-container {
background: transparent;
mask-image: linear-gradient(to top, black 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to top, black 60%, transparent 100%);
}
.chat-panel.theater-mode .messages-container :global(.chat-message) {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
border-radius: 4px;
padding: 0.5rem;
opacity: 0;
transition: opacity 0.5s;
}
.chat-panel.theater-mode:hover .messages-container :global(.chat-message),
.chat-panel.theater-mode.has-new-message .messages-container :global(.chat-message) {
opacity: 1;
}
.chat-panel.theater-mode :global(.chat-input) {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
border-color: rgba(255, 255, 255, 0.1);
opacity: 0;
transition: opacity 0.3s;
}
.chat-panel.theater-mode:hover :global(.chat-input),
.chat-panel.theater-mode.has-new-message :global(.chat-input) {
opacity: 1;
}
/* Disabled/empty states in theater mode */
.chat-panel.theater-mode .chat-disabled-message,
.chat-panel.theater-mode .empty-state {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
border-radius: 8px;
padding: 1rem;
opacity: 0.7;
}
/* Combined menu opens upward when chat is inverted (header at bottom) */
.chat-panel.inverted .combined-menu {
top: auto;
bottom: 38px; /* Height of header */
border-bottom: none;
border-top: 1px solid #333;
}
</style>