2026-01-05 22:54:27 -05:00
|
|
|
|
<script>
|
|
|
|
|
|
import { onMount, onDestroy } from 'svelte';
|
2026-01-08 22:57:43 -05:00
|
|
|
|
import { get } from 'svelte/store';
|
2026-01-05 22:54:27 -05:00
|
|
|
|
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';
|
2026-01-08 19:42:22 -05:00
|
|
|
|
import { auth, isAuthenticated } from '$lib/stores/auth';
|
2026-01-05 22:54:27 -05:00
|
|
|
|
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;
|
2026-01-08 22:57:43 -05:00
|
|
|
|
let initialScrollDone = false; // Prevent handleScroll from disabling autoScroll before initial scroll
|
2026-01-05 22:54:27 -05:00
|
|
|
|
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
|
2026-01-08 19:42:22 -05:00
|
|
|
|
let wasAuthenticated = false; // Track previous auth state for reconnect detection
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
|
|
$: isConnected = $connectionStatus === 'connected';
|
|
|
|
|
|
|
|
|
|
|
|
// Auto-load participants when connected
|
|
|
|
|
|
$: if (isConnected) {
|
|
|
|
|
|
chatWebSocket.getParticipants();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 19:42:22 -05:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-05 22:54:27 -05:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-01-08 19:42:22 -05:00
|
|
|
|
// Function to scroll to newest messages (bottom for UP flow, top for DOWN flow)
|
2026-01-05 22:54:27 -05:00
|
|
|
|
const scrollToBottom = () => {
|
|
|
|
|
|
if (autoScroll && messagesContainer) {
|
|
|
|
|
|
requestAnimationFrame(() => {
|
2026-01-08 22:57:43 -05:00
|
|
|
|
const messagesFromTop = get(chatLayout).messagesFromTop;
|
|
|
|
|
|
if (messagesFromTop) {
|
2026-01-08 19:42:22 -05:00
|
|
|
|
// column-reverse: newest at top, scroll to top
|
|
|
|
|
|
messagesContainer.scrollTop = 0;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// normal: newest at bottom, scroll to bottom
|
|
|
|
|
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
|
|
|
|
}
|
2026-01-08 22:57:43 -05:00
|
|
|
|
initialScrollDone = true;
|
2026-01-05 22:54:27 -05:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-01-06 23:20:31 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-05 22:54:27 -05:00
|
|
|
|
chatWebSocket.sendMessage(message, userColor, selfDestructSeconds || 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleScroll() {
|
2026-01-08 22:57:43 -05:00
|
|
|
|
// Ignore scroll events before initial scroll is done (prevents browser's initial position from disabling autoScroll)
|
|
|
|
|
|
if (!initialScrollDone) return;
|
|
|
|
|
|
|
2026-01-05 22:54:27 -05:00
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
|
2026-01-08 19:42:22 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-01-06 15:22:41 -05:00
|
|
|
|
// Note: State updates (localStorage + store) happen in rename_success handler
|
|
|
|
|
|
// to avoid updating UI before server confirms the rename
|
2026-01-05 22:54:27 -05:00
|
|
|
|
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}
|
2026-01-07 16:27:43 -05:00
|
|
|
|
isGuest={$chatUserInfo.isGuest}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
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: #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>
|