beeta/frontend/src/lib/components/chat/ChatPanel.svelte

1393 lines
36 KiB
Svelte
Raw Normal View History

2026-01-05 22:54:27 -05:00
<script>
import { onMount, onDestroy } from 'svelte';
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 {
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 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
$: isConnected = $connectionStatus === 'connected';
// Auto-load participants when connected
$: if (isConnected) {
chatWebSocket.getParticipants();
}
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;
}
// Get token from localStorage if available (for authenticated users)
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
console.log('Chat connecting with realmId:', realmId, token ? '(authenticated)' : '(guest)');
chatWebSocket.connect(realmId, token);
// Function to scroll to bottom
const scrollToBottom = () => {
if (autoScroll && messagesContainer) {
requestAnimationFrame(() => {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
}
};
// 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) {
const { message, selfDestructSeconds } = event.detail;
chatWebSocket.sendMessage(message, userColor, selfDestructSeconds || 0);
}
function handleScroll() {
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
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
if (chatWebSocket.sendRename(newGuestName.trim())) {
// Store in localStorage for persistence
localStorage.setItem('guestName', newGuestName.trim());
// Update local store
chatUserInfo.update(info => ({
...info,
username: 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}
headerBelow={$chatLayout.messagesFromTop}
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}
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;
border-left: 1px solid #333;
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: #0d0d0d;
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 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.messages-container::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}
.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: 'Consolas', 'Monaco', 'Courier New', monospace;
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>