fixes lol
Some checks failed
Build and Push / build-all (push) Failing after 2m22s

This commit is contained in:
doomtube 2026-01-09 21:27:30 -05:00
parent 6bbfc671b3
commit 2e376269c2
28 changed files with 389 additions and 332 deletions

View file

@ -73,6 +73,9 @@ function shouldSpeak(message) {
if (filter === 'guests' && !message.isGuest) return false;
if (filter === 'registered' && message.isGuest) return false;
// Skip graffiti messages (they're just images, nothing to read)
if (message.content && /^\[graffiti\].*\[\/graffiti\]$/i.test(message.content.trim())) return false;
return true;
}
@ -90,8 +93,11 @@ function cleanTextForTTS(text) {
clean = clean.replace(/~~(.+?)~~/g, '$1'); // strikethrough
clean = clean.replace(/`(.+?)`/g, '$1'); // code
// Remove URLs
clean = clean.replace(/https?:\/\/\S+/g, 'link');
// Remove URLs completely (don't read them aloud)
clean = clean.replace(/https?:\/\/\S+/g, '');
// Remove any graffiti tags that might be embedded
clean = clean.replace(/\[graffiti\].*?\[\/graffiti\]/gi, '');
// Limit length to prevent very long TTS
if (clean.length > 200) {

View file

@ -1,6 +1,8 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { gamesOverlay, hasGame, currentGame, gameMode } from '$lib/stores/gamesOverlay';
import { nakama, ChessOpCode } from '$lib/stores/nakama';
import { auth } from '$lib/stores/auth';
@ -390,6 +392,11 @@
myColor = null;
gamesOverlay.closeGame();
// Clear match param from URL if on chess page
if ($page.url.pathname === '/games/chess' && $page.url.searchParams.has('match')) {
goto('/games/chess', { replaceState: true });
}
}
function handleMinimize() {

View file

@ -114,8 +114,8 @@
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: #1a1a1a;
border: 1px solid var(--border, #333);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
width: 300px;
max-width: 90vw;
@ -140,7 +140,7 @@
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border, #333);
border-bottom: 1px solid var(--border);
background: rgba(255, 215, 0, 0.05);
}
@ -148,7 +148,7 @@
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: white;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
@ -170,7 +170,7 @@
.close-btn {
background: transparent;
border: none;
color: #666;
color: var(--text-faint);
font-size: 1.1rem;
cursor: pointer;
padding: 2px 6px;
@ -180,7 +180,7 @@
}
.close-btn:hover {
color: white;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
}
@ -197,11 +197,11 @@
}
.recipient-info span {
color: #888;
color: var(--text-muted);
}
.recipient-info strong {
color: white;
color: var(--text-primary);
}
.balance-row {
@ -213,11 +213,11 @@
border-radius: 6px;
margin-bottom: 12px;
font-size: 0.85rem;
color: #ccc;
color: var(--text-secondary);
}
.balance-row strong {
color: #ffd700;
color: var(--accent-gold);
}
.coin-icon {
@ -240,24 +240,24 @@
.input-group label {
display: block;
font-size: 0.8rem;
color: #888;
color: var(--text-muted);
margin-bottom: 4px;
}
.input-group input {
width: 100%;
padding: 8px 10px;
background: #222;
border: 1px solid var(--border, #333);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 4px;
color: white;
color: var(--text-primary);
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s ease;
}
.input-group input:focus {
border-color: #ffd700;
border-color: var(--accent-gold);
}
.input-group input:disabled {
@ -303,12 +303,12 @@
.burn-details p {
margin: 0 0 2px 0;
font-size: 0.8rem;
color: #ccc;
color: var(--text-secondary);
}
.burn-details .detail-small {
font-size: 0.75rem;
color: #888;
color: var(--text-muted);
}
.preview-loading {
@ -320,7 +320,7 @@
border-radius: 6px;
margin-bottom: 12px;
font-size: 0.8rem;
color: #888;
color: var(--text-muted);
}
.error-message {
@ -348,7 +348,7 @@
display: flex;
gap: 10px;
padding: 12px 14px;
border-top: 1px solid var(--border, #333);
border-top: 1px solid var(--border);
}
.panel-footer button {
@ -363,8 +363,8 @@
.cancel-btn {
background: transparent;
border: 1px solid var(--border, #333);
color: white;
border: 1px solid var(--border);
color: var(--text-primary);
}
.cancel-btn:hover:not(:disabled) {
@ -390,7 +390,7 @@
</style>
{#if show}
<div class="tip-panel" bind:this={panelElement} role="dialog" aria-modal="true">
<div class="tip-panel" bind:this={panelElement} role="dialog" aria-modal="true" on:click|stopPropagation>
<div class="panel-header">
<h3>
<span class="header-icon">Ü</span>

View file

@ -407,7 +407,7 @@
.chat-input {
display: flex;
gap: 0.5rem;
padding: 0;
padding: 2px 2px;
background: var(--bg-surface);
flex-shrink: 0; /* Prevent input from shrinking */
}

View file

@ -745,7 +745,7 @@
align-items: center;
padding: 0.5rem;
border-bottom: 1px solid #333;
background: #0d0d0d;
background: #000;
flex-shrink: 0;
position: relative;
}

View file

@ -96,6 +96,10 @@
}
function stopResize() {
// Save height to localStorage after resize ends
if (isResizing && isDocked) {
localStorage.setItem('terminalHeight', String(terminalHeight));
}
isResizing = false;
isDragging = false;
}
@ -111,7 +115,9 @@
function toggleDock() {
isDocked = !isDocked;
if (isDocked) {
terminalHeight = 500;
// Restore saved height or use default
const savedHeight = localStorage.getItem('terminalHeight');
terminalHeight = savedHeight ? parseInt(savedHeight, 10) || 333 : 333;
}
}
@ -260,6 +266,14 @@
terminalHotkey = savedHotkey;
}
const savedHeight = localStorage.getItem('terminalHeight');
if (savedHeight) {
const height = parseInt(savedHeight, 10);
if (!isNaN(height) && height >= 200) {
terminalHeight = height;
}
}
// Update time every second
timeInterval = setInterval(() => {
currentTime = new Date();

View file

@ -1,7 +1,7 @@
<script>
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
import { browser } from '$app/environment';
import { filteredMessages, connectionStatus, chatUserInfo, fetchRealmStats, availableRealms } from '$lib/chat/chatStore';
import { filteredMessages, connectionStatus, chatUserInfo, fetchRealmStats, availableRealms, currentRealmId } from '$lib/chat/chatStore';
import { chatWebSocket } from '$lib/chat/chatWebSocket';
import { auth, userColor } from '$lib/stores/auth';
import ChatMessage from '$lib/components/chat/ChatMessage.svelte';
@ -36,6 +36,32 @@
$: isConnected = $connectionStatus === 'connected';
$: username = $auth.user?.username || $chatUserInfo.username || 'guest';
// Sync local realmId with store's currentRealmId when it changes (e.g., from WebSocket connection)
$: if ($currentRealmId && $currentRealmId !== realmId) {
realmId = $currentRealmId;
dispatch('realmChange', { realmId });
}
// Create a merged and sorted array of all messages (system + chat) for chronological display
$: allMessages = (() => {
// Convert system messages to unified format with numeric timestamps
const sysWithTime = systemMessages.map(s => ({
...s,
type: 'system',
sortTime: s.id // id is Date.now() + Math.random(), so it works for sorting
}));
// Convert chat messages to unified format
const chatWithTime = $filteredMessages.map(m => ({
...m,
type: 'chat',
sortTime: typeof m.timestamp === 'number' ? m.timestamp : new Date(m.timestamp).getTime()
}));
// Merge and sort by time
return [...sysWithTime, ...chatWithTime].sort((a, b) => a.sortTime - b.sortTime);
})();
// Exposed methods
export function addSystemMessage(text) {
systemMessages = [...systemMessages, {
@ -157,8 +183,7 @@
// Auto-scroll when messages change
$: if (browser) {
systemMessages.length;
$filteredMessages.length;
allMessages.length;
if (isActive) {
scrollToBottom();
}
@ -223,30 +248,30 @@
</script>
<div class="terminal-messages" on:scroll={handleScroll}>
{#each systemMessages as sysMsg (sysMsg.id)}
<div class="system-message">
<span class="system-prefix">[{sysMsg.timestamp}]</span>
<span class="system-text">{sysMsg.text}</span>
</div>
{/each}
{#each $filteredMessages as message (message.messageId)}
<ChatMessage
{message}
showHeader={true}
currentUserId={$chatUserInfo.userId}
currentRealmId={realmId}
isModerator={$chatUserInfo.isModerator}
terminalMode={true}
{renderStickers}
on:delete={() => chatWebSocket.deleteMessage(message.messageId)}
on:showProfile={handleShowProfile}
/>
{#each allMessages as msg (msg.type === 'system' ? msg.id : msg.messageId)}
{#if msg.type === 'system'}
<div class="system-message">
<span class="system-prefix">[{msg.timestamp}]</span>
<span class="system-text">{msg.text}</span>
</div>
{:else}
<ChatMessage
message={msg}
showHeader={true}
currentUserId={$chatUserInfo.userId}
currentRealmId={realmId}
isModerator={$chatUserInfo.isModerator}
terminalMode={true}
{renderStickers}
on:delete={() => chatWebSocket.deleteMessage(msg.messageId)}
on:showProfile={handleShowProfile}
/>
{/if}
{/each}
<!-- Inline command input at end of messages -->
<form class="terminal-input-line" on:submit={handleCommand}>
<span class="prompt">{username}@realms:~$</span>
<span class="prompt" class:disconnected={!isConnected}>{username}@{realmId || 'global'}:~$</span>
<input
type="text"
class="terminal-input"
@ -283,6 +308,10 @@
font-size: 0.8rem;
}
.prompt.disconnected {
color: #f85149;
}
.terminal-input {
flex: 1;
background: transparent;

View file

@ -10,7 +10,8 @@ import {
leaveRealmFilter,
resetToGlobal,
fetchRealmStats,
chatUserInfo
chatUserInfo,
currentRealmId
} from '$lib/chat/chatStore';
import { get } from 'svelte/store';
@ -307,6 +308,7 @@ async function listRealms(addSystemMessage) {
await fetchRealmStats();
const realms = get(availableRealms);
const selected = get(selectedRealms);
const connected = get(currentRealmId);
const isGlobal = selected.size === 0;
addSystemMessage('=== Available Realms ===');
@ -314,17 +316,20 @@ async function listRealms(addSystemMessage) {
addSystemMessage('No active realms');
} else {
realms.forEach((realm) => {
const isConnected = String(realm.realmId) === String(connected);
const checked = isGlobal || selected.has(realm.realmId) ? '[✓]' : '[ ]';
const connMarker = isConnected ? ' <--' : '';
const users = realm.participantCount || 0;
addSystemMessage(`${checked} ${realm.realmId} (${users} users)`);
addSystemMessage(`${checked} ${realm.realmId} (${users} users)${connMarker}`);
});
}
addSystemMessage('');
addSystemMessage(`Connected to: ${connected || 'none'} (messages sent here)`);
if (isGlobal) {
addSystemMessage('Currently: Global (all realms)');
addSystemMessage('Viewing: Global (all realms)');
} else {
const names = Array.from(selected).join(', ');
addSystemMessage(`Filtered to: ${names}`);
addSystemMessage(`Viewing: ${names}`);
}
addSystemMessage('========================');
} catch (error) {
@ -350,7 +355,7 @@ async function joinRealmChat(nameOrId, addSystemMessage, chatWebSocket, setRealm
if (response.ok) {
const data = await response.json();
targetRealmId = String(data.realm.id);
realmName = data.realm.name || nameOrId;
realmName = data.realm.displayName || data.realm.name || nameOrId;
} else {
addSystemMessage(`Realm "${nameOrId}" not found`);
return;

View file

@ -322,7 +322,7 @@
<style>
.playlist-container {
background: #1a1a1a;
background: #000;
display: flex;
flex-direction: column;
height: 100%;

View file

@ -19,11 +19,16 @@
let currentPlaylistItemId = null; // Track playlist item ID to detect changes even with same video
let durationReportedForItemId = null; // Track which playlist item we've reported duration for
let lastControllerSeekTime = 0; // Debounce controller seek updates
let lastSeekTime = 0; // Track last seek time for rate-limiting
let leadInEndedAt = 0; // Track when lead-in ended for grace period
let wasInLeadIn = false; // Track previous lead-in state
const SYNC_CHECK_INTERVAL = 1000; // Check sync every second (matches server sync interval)
const DRIFT_THRESHOLD = 2; // Seek if drift > 2 seconds (CyTube-style tight sync)
const MIN_SYNC_INTERVAL = 1000; // Minimum 1 second between sync checks (server pushes every 1s)
const CONTROLLER_SEEK_DEBOUNCE = 2000; // Debounce controller seeks by 2 seconds
const SEEK_RATE_LIMIT = 2000; // Minimum 2 seconds between seeks
const POST_LEAD_IN_GRACE = 500; // 500ms grace period after lead-in ends
// Load YouTube IFrame API
function loadYouTubeAPI() {
@ -171,16 +176,28 @@
// During lead-in period, let video buffer without seeking
// Server sends leadIn=true for 3 seconds after play starts
if (storeState.leadIn) {
wasInLeadIn = true;
// During lead-in, just ensure video is loading/buffering
// Don't seek or sync position - wait for lead-in to complete
if (shouldBePlaying && !isPlayerPlaying && !isBuffering && !hasVideoEnded) {
ignoreStateChange = true;
player.playVideo();
setTimeout(() => { ignoreStateChange = false; }, 500);
setTimeout(() => { ignoreStateChange = false; }, 1500);
}
return;
}
// Track when lead-in ends for grace period
if (wasInLeadIn && !storeState.leadIn) {
wasInLeadIn = false;
leadInEndedAt = now;
}
// Apply grace period after lead-in ends - don't seek immediately
if (leadInEndedAt > 0 && (now - leadInEndedAt) < POST_LEAD_IN_GRACE) {
return;
}
const expectedTime = watchSync.getExpectedTime();
const currentTime = player.getCurrentTime();
const drift = Math.abs(currentTime - expectedTime);
@ -205,10 +222,15 @@
}
} else {
// Non-controller - sync back to server time
// Rate-limit seeks to prevent stuttering from consecutive seeks
if (now - lastSeekTime < SEEK_RATE_LIMIT) {
return; // Skip this seek, wait for rate limit
}
console.log(`Sync drift detected: ${drift.toFixed(2)}s, seeking to ${expectedTime.toFixed(2)}s`);
ignoreStateChange = true;
player.seekTo(expectedTime, true);
setTimeout(() => { ignoreStateChange = false; }, 500);
lastSeekTime = now;
setTimeout(() => { ignoreStateChange = false; }, 1500);
}
}
@ -217,11 +239,11 @@
if (shouldBePlaying && !isPlayerPlaying && !isBuffering && !hasVideoEnded) {
ignoreStateChange = true;
player.playVideo();
setTimeout(() => { ignoreStateChange = false; }, 500);
setTimeout(() => { ignoreStateChange = false; }, 1500);
} else if (!shouldBePlaying && isPlayerPlaying) {
ignoreStateChange = true;
player.pauseVideo();
setTimeout(() => { ignoreStateChange = false; }, 500);
setTimeout(() => { ignoreStateChange = false; }, 1500);
}
}
@ -245,11 +267,9 @@
}
}
// React to state changes from the store
$: if (playerReady && player && $watchSync.serverTime > lastSyncTime) {
lastSyncTime = $watchSync.serverTime;
checkAndSync();
}
// Note: Sync is handled by the interval at onPlayerReady (line 99)
// We don't trigger sync on every serverTime update to avoid double-syncing
// which causes stuttering. The interval runs every 1 second which is sufficient.
// Handle window event for state changes from other users
function handleStateChange(event) {
@ -270,6 +290,11 @@
} else if (action === 'skip' || action === 'video_changed') {
// Video change will be handled by the reactive statement above
setTimeout(() => checkAndSync(true), 1000);
} else if (action === 'locked_restart' || action === 'repeat') {
// Locked video loop - seek to beginning and play
player.seekTo(0, true);
player.playVideo();
setTimeout(() => checkAndSync(true), 1000);
}
setTimeout(() => { ignoreStateChange = false; }, 1000);
@ -280,7 +305,7 @@
if (playerReady && player) {
ignoreStateChange = true;
player.seekTo(time, true);
setTimeout(() => { ignoreStateChange = false; }, 500);
setTimeout(() => { ignoreStateChange = false; }, 1500);
}
}

View file

@ -484,12 +484,69 @@ function createAudioPlaylistStore() {
...state,
nowPlaying: null
}));
},
// Sync state from localStorage (called when storage event fires from another tab)
// Only syncs queue/settings, not playback state to avoid one tab controlling another
syncFromStorage(syncedState) {
update(state => {
// Determine if we need to adjust currentIndex
let newIndex = state.currentIndex;
const currentTrackId = state.queue[state.currentIndex]?.id;
// If queue changed and we had a current track, try to find it in new queue
if (currentTrackId && syncedState.queue) {
const newPosition = syncedState.queue.findIndex(t => t.id === currentTrackId);
if (newPosition >= 0) {
newIndex = newPosition;
} else if (newIndex >= syncedState.queue.length) {
// Current track was removed, adjust to valid index
newIndex = Math.max(0, syncedState.queue.length - 1);
}
}
return {
...state,
queue: syncedState.queue ?? state.queue,
currentIndex: newIndex,
volume: syncedState.volume ?? state.volume,
muted: syncedState.muted ?? state.muted,
shuffle: syncedState.shuffle ?? state.shuffle,
repeat: syncedState.repeat ?? state.repeat,
minimized: syncedState.minimized ?? state.minimized,
// Keep current playback state for this tab
enabled: state.enabled || (syncedState.queue?.length > 0)
};
});
}
};
}
export const audioPlaylist = createAudioPlaylistStore();
// Cross-tab synchronization: listen for localStorage changes from other tabs
if (browser) {
window.addEventListener('storage', (event) => {
if (event.key === STORAGE_KEY && event.newValue) {
try {
const parsed = JSON.parse(event.newValue);
// Only sync queue-related state, not playback state
// This prevents one tab from controlling another's playback
audioPlaylist.syncFromStorage({
queue: parsed.queue || [],
volume: parsed.volume ?? 1.0,
muted: parsed.muted ?? false,
shuffle: parsed.shuffle ?? false,
repeat: parsed.repeat ?? 'none',
minimized: parsed.minimized ?? false
});
} catch (e) {
console.error('Failed to sync audio playlist from storage:', e);
}
}
});
}
// Derived store for current track (nowPlaying takes precedence over queue)
export const currentTrack = derived(audioPlaylist, $playlist => {
// If there's a nowPlaying track, that's the current track

View file

@ -687,7 +687,7 @@
</script>
<svelte:head>
<title>{realm ? `${$siteSettings.site_title} - ${realm.name}` : $siteSettings.site_title}</title>
<title>{realm ? `${$siteSettings.site_title} - ${realm.displayName || realm.name}` : $siteSettings.site_title}</title>
</svelte:head>
<style>
@ -1356,7 +1356,7 @@
{:else}
<div class="offline-placeholder">
<div class="offline-icon"></div>
<div class="offline-text">{realm.name}</div>
<div class="offline-text">{realm.displayName || realm.name}</div>
</div>
{/if}
<div class="offline-badge">OFFLINE</div>
@ -1389,7 +1389,7 @@
{:else}
<div class="offline-placeholder">
<div class="offline-icon"></div>
<div class="offline-text">{realm.name}</div>
<div class="offline-text">{realm.displayName || realm.name}</div>
</div>
{/if}
<div class="offline-badge">OFFLINE</div>
@ -1444,7 +1444,7 @@
{:else}
<div class="offline-placeholder">
<div class="offline-icon"></div>
<div class="offline-text">{realm.name}</div>
<div class="offline-text">{realm.displayName || realm.name}</div>
</div>
{/if}
<div class="offline-badge">OFFLINE</div>
@ -1457,7 +1457,7 @@
<div class="stream-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
<div class="stream-header">
<div class="header-top">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
<div class="live-status-badge" class:live={stats.isLive}>
<span class="badge-segment">{stats.isLive ? stats.connections : realm.viewerCount} viewers</span>
{#if stats.isLive && stats.bitrate > 0}

View file

@ -226,7 +226,7 @@
</script>
<svelte:head>
<title>{realm ? `${$siteSettings.site_title} - ${realm.name} Watch` : $siteSettings.site_title}</title>
<title>{realm ? `${$siteSettings.site_title} - ${realm.displayName || realm.name} Watch` : $siteSettings.site_title}</title>
</svelte:head>
<style>
@ -489,7 +489,7 @@
<PlaybackControls
{currentTime}
duration={$currentVideo?.durationSeconds || duration}
realmName={realm.name}
realmName={realm.displayName || realm.name}
username={realm.username}
titleColor={realm.titleColor}
colorCode={realm.colorCode}

View file

@ -2795,7 +2795,7 @@
{/if}
</td>
<td>
<div style="font-weight: 600;">{realm.name}</div>
<div style="font-weight: 600;">{realm.displayName || realm.name}</div>
{#if realm.description}
<div style="font-size: 0.85rem; color: var(--gray); margin-top: 0.25rem;">
{realm.description}

View file

@ -412,7 +412,7 @@
</div>
{:else if realm}
<div class="realm-header">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}

View file

@ -412,7 +412,7 @@
</div>
{:else if realm}
<div class="realm-header">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}

View file

@ -368,7 +368,7 @@
</div>
{:else if realm}
<div class="realm-header">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}

View file

@ -212,6 +212,8 @@
// Server will send 'playing' if we're a player or 'spectating' if game is full
console.log('[ChessPage] Opening overlay for match:', matchId);
gamesOverlay.openGame(matchId, null);
// Update URL so refresh will rejoin the game
goto(`/games/chess?match=${matchId}`, { replaceState: true, noScroll: true });
} else {
error = `Failed to join: ${joinResult.error}`;
}
@ -239,6 +241,8 @@
if (joinResult.success) {
// Don't force mode - let server GAME_STATE determine if spectator or player
gamesOverlay.openGame(matchId, null);
// Update URL so refresh will rejoin as spectator
goto(`/games/chess?match=${matchId}`, { replaceState: true, noScroll: true });
} else {
error = `Failed to spectate: ${joinResult.error}`;
}
@ -259,10 +263,10 @@
}
onMount(() => {
// Don't close overlay on mount - URL param handles state recovery
// If there's no match param, the overlay won't be open anyway after refresh
// Note: Initial loadLobbyData is triggered by the reactive statement when auth finishes loading
if (!matchIdFromUrl) {
// Close any stale overlay when visiting lobby without a match param
gamesOverlay.closeGame();
// Note: Initial loadLobbyData is triggered by the reactive statement when auth finishes loading
refreshInterval = setInterval(loadLobbyData, GAMES_POLL_INTERVAL);
}
});

View file

@ -220,7 +220,7 @@
error = '';
if (!validateRealmName(newRealmName)) {
error = 'Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only';
error = 'Realm name must be 3-30 characters, letters, numbers, and hyphens only';
return;
}
@ -281,7 +281,7 @@
}
async function deleteRealm(realm) {
if (!confirm(`Delete realm "${realm.name}"? This action cannot be undone.`)) {
if (!confirm(`Delete realm "${realm.displayName || realm.name}"? This action cannot be undone.`)) {
return;
}
@ -333,7 +333,8 @@
}
function validateRealmName(name) {
return /^[a-z0-9-]{3,30}$/.test(name);
// Allow uppercase letters - URL will be lowercase
return /^[a-zA-Z0-9-]{3,30}$/.test(name);
}
function copyToClipboard(text) {
@ -2777,10 +2778,10 @@
{(realm.type || 'stream') === 'stream' ? 'Stream' : realm.type === 'video' ? 'Video' : realm.type === 'audio' ? 'Audio' : realm.type === 'watch' ? 'Watch' : 'Ebook'}
</span>
</div>
<p style="margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--gray);">Lowercase letters, numbers, and hyphens only (3-30 chars)</p>
<p style="margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--gray);">Letters, numbers, and hyphens only (3-30 chars). URL will be lowercase.</p>
{:else}
<h3>
<span style="cursor: pointer; color: {realm.titleColor || '#ffffff'};" on:click={() => startEditName(realm)} title="Click to rename">{realm.name}</span>
<span style="cursor: pointer; color: {realm.titleColor || '#ffffff'};" on:click={() => startEditName(realm)} title="Click to rename">{realm.displayName || realm.name}</span>
<button class="btn" style="padding: 0.15rem 0.5rem; font-size: 0.7rem; margin-left: 0.5rem;" on:click={() => startEditName(realm)}>Rename</button>
<span class="realm-type-badge {realm.type || 'stream'}">
{(realm.type || 'stream') === 'stream' ? 'Stream' : realm.type === 'video' ? 'Video' : realm.type === 'audio' ? 'Audio' : realm.type === 'watch' ? 'Watch' : 'Ebook'}

View file

@ -772,7 +772,7 @@
<span class="live-badge">LIVE</span>
{/if}
</div>
<h4 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h4>
<h4 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h4>
<div class="realm-meta">
{#if realm.type === 'video'}
<span>{realm.videoCount || 0} videos</span>

View file

@ -294,7 +294,7 @@
</div>
{:else if realm}
<div class="realm-header">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}

View file

@ -268,7 +268,7 @@
</div>
{:else if realm}
<div class="realm-header">
<h1>{realm.name}</h1>
<h1>{realm.displayName || realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}