Initial commit - realms platform

This commit is contained in:
doomtube 2026-01-05 22:54:27 -05:00
parent c590ab6d18
commit c717c3751c
234 changed files with 74103 additions and 15231 deletions

View file

@ -0,0 +1,667 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { audioPlaylist, currentTrack, hasNext, hasPrevious } from '$lib/stores/audioPlaylist';
let seekBar;
let volumeBar;
let isDraggingSeek = false;
let isDraggingVolume = false;
let seekPosition = 0;
// Format time in mm:ss
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// Seek bar handling
function handleSeekStart(e) {
isDraggingSeek = true;
handleSeekMove(e);
}
function handleSeekMove(e) {
if (!isDraggingSeek || !seekBar) return;
const rect = seekBar.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
seekPosition = Math.max(0, Math.min(1, x / rect.width));
}
function handleSeekEnd() {
if (isDraggingSeek) {
const newTime = seekPosition * $audioPlaylist.duration;
audioPlaylist.seek(newTime);
}
isDraggingSeek = false;
}
// Volume bar handling
function handleVolumeStart(e) {
isDraggingVolume = true;
handleVolumeMove(e);
}
function handleVolumeMove(e) {
if (!isDraggingVolume || !volumeBar) return;
const rect = volumeBar.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const volume = Math.max(0, Math.min(1, x / rect.width));
audioPlaylist.setVolume(volume);
}
function handleVolumeEnd() {
isDraggingVolume = false;
}
// Keyboard shortcuts
function handleKeydown(e) {
if (!$audioPlaylist.enabled) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
switch (e.code) {
case 'Space':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
audioPlaylist.togglePlay();
}
break;
case 'ArrowRight':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
audioPlaylist.next();
}
break;
case 'ArrowLeft':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
audioPlaylist.previous();
}
break;
}
}
onMount(() => {
if (browser) {
window.addEventListener('mousemove', handleSeekMove);
window.addEventListener('mouseup', handleSeekEnd);
window.addEventListener('touchmove', handleSeekMove);
window.addEventListener('touchend', handleSeekEnd);
window.addEventListener('mousemove', handleVolumeMove);
window.addEventListener('mouseup', handleVolumeEnd);
window.addEventListener('touchmove', handleVolumeMove);
window.addEventListener('touchend', handleVolumeEnd);
window.addEventListener('keydown', handleKeydown);
}
});
onDestroy(() => {
if (browser) {
window.removeEventListener('mousemove', handleSeekMove);
window.removeEventListener('mouseup', handleSeekEnd);
window.removeEventListener('touchmove', handleSeekMove);
window.removeEventListener('touchend', handleSeekEnd);
window.removeEventListener('mousemove', handleVolumeMove);
window.removeEventListener('mouseup', handleVolumeEnd);
window.removeEventListener('touchmove', handleVolumeMove);
window.removeEventListener('touchend', handleVolumeEnd);
window.removeEventListener('keydown', handleKeydown);
}
});
// Calculate progress percentage
$: progress = $audioPlaylist.duration > 0
? (isDraggingSeek ? seekPosition : $audioPlaylist.currentTime / $audioPlaylist.duration) * 100
: 0;
</script>
{#if $audioPlaylist.enabled && ($audioPlaylist.queue.length > 0 || $audioPlaylist.nowPlaying)}
<div class="audio-player" class:minimized={$audioPlaylist.minimized}>
{#if $audioPlaylist.minimized}
<!-- Minimized view -->
<div class="mini-player">
<div class="mini-thumb" on:click={() => audioPlaylist.toggleMinimized()}>
{#if $currentTrack?.thumbnailPath}
<img src={$currentTrack.thumbnailPath} alt="" />
{:else}
<div class="mini-thumb-placeholder">
<span>{$currentTrack?.title?.charAt(0) || '?'}</span>
</div>
{/if}
<div class="mini-progress" style="width: {progress}%"></div>
</div>
<button class="mini-play" on:click={() => audioPlaylist.togglePlay()}>
{$audioPlaylist.isPlaying ? '⏸' : '▶'}
</button>
</div>
{:else}
<!-- Full player view -->
<div class="player-header">
<span class="queue-count">{$audioPlaylist.currentIndex + 1} / {$audioPlaylist.queue.length}</span>
<div class="header-controls">
<button class="header-btn" on:click={() => audioPlaylist.toggleMinimized()} title="Minimize">
</button>
<button class="header-btn close" on:click={() => audioPlaylist.hide()} title="Close">
×
</button>
</div>
</div>
<div class="player-content">
<!-- Track info -->
<div class="track-info">
<div class="track-thumb">
{#if $currentTrack?.thumbnailPath}
<img src={$currentTrack.thumbnailPath} alt="" />
{:else}
<div class="thumb-placeholder">
<span>{$currentTrack?.title?.charAt(0) || '?'}</span>
</div>
{/if}
</div>
<div class="track-details">
<div class="track-title" title={$currentTrack?.title || 'Unknown'}>
{$currentTrack?.title || 'Unknown'}
</div>
<div class="track-artist">
{$currentTrack?.username || 'Unknown Artist'}
{#if $currentTrack?.realmName}
<span class="track-realm">in {$currentTrack.realmName}</span>
{/if}
</div>
</div>
</div>
<!-- Progress bar -->
<div class="progress-container">
<span class="time">{formatTime(isDraggingSeek ? seekPosition * $audioPlaylist.duration : $audioPlaylist.currentTime)}</span>
<div
class="progress-bar"
bind:this={seekBar}
on:mousedown={handleSeekStart}
on:touchstart={handleSeekStart}
>
<div class="progress-fill" style="width: {progress}%"></div>
<div class="progress-thumb" style="left: {progress}%"></div>
</div>
<span class="time">{formatTime($audioPlaylist.duration)}</span>
</div>
<!-- Main controls -->
<div class="main-controls">
<button
class="control-btn secondary"
class:active={$audioPlaylist.shuffle}
on:click={() => audioPlaylist.toggleShuffle()}
title="Shuffle"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 3 21 3 21 8"></polyline>
<line x1="4" y1="20" x2="21" y2="3"></line>
<polyline points="21 16 21 21 16 21"></polyline>
<line x1="15" y1="15" x2="21" y2="21"></line>
<line x1="4" y1="4" x2="9" y2="9"></line>
</svg>
</button>
<button
class="control-btn"
disabled={!$hasPrevious}
on:click={() => audioPlaylist.previous()}
title="Previous"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6v-12zm3.5 6l8.5 6V6l-8.5 6z"/>
</svg>
</button>
<button class="control-btn play" on:click={() => audioPlaylist.togglePlay()}>
{#if $audioPlaylist.isPlaying}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
<button
class="control-btn"
disabled={!$hasNext}
on:click={() => audioPlaylist.next()}
title="Next"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z"/>
</svg>
</button>
<button
class="control-btn secondary"
class:active={$audioPlaylist.repeat !== 'none'}
on:click={() => audioPlaylist.cycleRepeat()}
title={`Repeat: ${$audioPlaylist.repeat}`}
>
{#if $audioPlaylist.repeat === 'one'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
<text x="12" y="14" font-size="7" fill="currentColor" stroke="none" text-anchor="middle" dominant-baseline="middle">1</text>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
{/if}
</button>
</div>
<!-- Volume control -->
<div class="volume-container">
<button
class="control-btn secondary"
on:click={() => audioPlaylist.toggleMute()}
title={$audioPlaylist.muted ? 'Unmute' : 'Mute'}
>
{#if $audioPlaylist.muted || $audioPlaylist.volume === 0}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
{:else if $audioPlaylist.volume < 0.5}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
{/if}
</button>
<div
class="volume-bar"
bind:this={volumeBar}
on:mousedown={handleVolumeStart}
on:touchstart={handleVolumeStart}
>
<div class="volume-fill" style="width: {$audioPlaylist.muted ? 0 : $audioPlaylist.volume * 100}%"></div>
<div class="volume-thumb" style="left: {$audioPlaylist.muted ? 0 : $audioPlaylist.volume * 100}%"></div>
</div>
</div>
</div>
{/if}
</div>
{/if}
<style>
.audio-player {
position: fixed;
bottom: 1rem;
right: 1rem;
width: 320px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 9998;
overflow: hidden;
}
.audio-player.minimized {
width: auto;
border-radius: 50px;
}
/* Mini player */
.mini-player {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem;
}
.mini-thumb {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
position: relative;
flex-shrink: 0;
}
.mini-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mini-thumb-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #561d5e, #8b3a92);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 1rem;
}
.mini-progress {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: #8b3a92;
border-radius: 0 0 0 50%;
transition: width 0.1s linear;
}
.mini-play {
width: 36px;
height: 36px;
border: none;
background: #8b3a92;
color: white;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
transition: transform 0.15s ease, background 0.15s ease;
}
.mini-play:hover {
background: #a64daf;
transform: scale(1.05);
}
/* Full player */
.player-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #0d1117;
border-bottom: 1px solid #30363d;
}
.queue-count {
color: #8b949e;
font-size: 0.75rem;
}
.header-controls {
display: flex;
gap: 0.25rem;
}
.header-btn {
width: 24px;
height: 24px;
border: none;
background: none;
color: #8b949e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 1rem;
transition: all 0.15s ease;
}
.header-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #c9d1d9;
}
.header-btn.close:hover {
color: #f85149;
}
.player-content {
padding: 0.75rem;
}
/* Track info */
.track-info {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.track-thumb {
width: 48px;
height: 48px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.track-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #561d5e, #8b3a92);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 1.2rem;
}
.track-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.track-title {
color: #c9d1d9;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
color: #8b949e;
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-realm {
color: #6e7681;
}
/* Progress bar */
.progress-container {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.time {
color: #8b949e;
font-size: 0.7rem;
font-family: monospace;
min-width: 32px;
}
.time:last-child {
text-align: right;
}
.progress-bar {
flex: 1;
height: 4px;
background: #30363d;
border-radius: 2px;
cursor: pointer;
position: relative;
}
.progress-fill {
height: 100%;
background: #8b3a92;
border-radius: 2px;
transition: width 0.1s linear;
}
.progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
opacity: 0;
transition: opacity 0.15s ease;
}
.progress-bar:hover .progress-thumb {
opacity: 1;
}
/* Main controls */
.main-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.control-btn {
width: 32px;
height: 32px;
border: none;
background: none;
color: #c9d1d9;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.15s ease;
}
.control-btn svg {
width: 20px;
height: 20px;
}
.control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.control-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.control-btn.secondary {
color: #8b949e;
}
.control-btn.secondary svg {
width: 16px;
height: 16px;
}
.control-btn.secondary.active {
color: #8b3a92;
}
.control-btn.play {
width: 40px;
height: 40px;
background: #8b3a92;
color: white;
}
.control-btn.play:hover {
background: #a64daf;
transform: scale(1.05);
}
.control-btn.play svg {
width: 24px;
height: 24px;
}
/* Volume control */
.volume-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.volume-bar {
flex: 1;
min-width: 100px;
height: 4px;
background: #30363d;
border-radius: 2px;
cursor: pointer;
position: relative;
}
.volume-fill {
height: 100%;
background: #8b3a92;
border-radius: 2px;
transition: width 0.1s linear;
}
.volume-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background: white;
border-radius: 50%;
opacity: 0;
transition: opacity 0.15s ease;
}
.volume-bar:hover .volume-thumb {
opacity: 1;
}
@media (max-width: 400px) {
.audio-player {
width: calc(100vw - 2rem);
right: 1rem;
}
}
</style>

View file

@ -0,0 +1,919 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { gamesOverlay, hasGame, currentGame, gameMode } from '$lib/stores/gamesOverlay';
import { nakama, ChessOpCode } from '$lib/stores/nakama';
import { auth } from '$lib/stores/auth';
// Chess state
let game = null;
let Chess = null;
let boardSquares = [];
let selectedSquare = null;
let legalMoves = [];
let moveHistory = [];
// Match state from overlay store
let myColor = null;
// Drag state
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
let position = { x: null, y: null };
let isResizing = false;
let resizeDirection = '';
let dimensions = { width: 450, height: 520 };
let startDimensions = { width: 0, height: 0 };
let startPos = { x: 0, y: 0 };
// Callbacks cleanup
let unsubscribeMatch = null;
let isDestroyed = false;
// Load/save position
function loadPosition() {
if (!browser) return;
try {
const saved = localStorage.getItem('chessGameOverlayPosition');
if (saved) {
const parsed = JSON.parse(saved);
position = parsed.position || { x: null, y: null };
dimensions = parsed.dimensions || { width: 450, height: 520 };
}
} catch (e) {}
}
function savePosition() {
if (!browser) return;
try {
localStorage.setItem('chessGameOverlayPosition', JSON.stringify({
position,
dimensions
}));
} catch (e) {}
}
// Computed position (default to center-right if not set)
$: computedPosition = {
right: position.x === null ? '1rem' : 'auto',
top: position.y === null ? '50%' : 'auto',
left: position.x !== null ? `${position.x}px` : 'auto',
bottom: position.y !== null ? `${position.y}px` : 'auto',
transform: position.y === null ? 'translateY(-50%)' : 'none'
};
// Calculate chess board cell size based on container dimensions
// Account for header (~45px), footer (~45px), board border/padding (~20px)
$: cellSize = Math.floor(Math.min(dimensions.width - 30, dimensions.height - 120) / 8);
async function initChess() {
console.log('[ChessOverlay] initChess called, matchId:', $gamesOverlay.matchId);
if (!browser) return;
try {
const chessModule = await import('chess.js');
Chess = chessModule.Chess;
game = new Chess();
// Set up match event handler
console.log('[ChessOverlay] Registering match event handler...');
unsubscribeMatch = nakama.onMatchEvent(handleMatchEvent);
console.log('[ChessOverlay] Handler registered');
updateBoardDisplay();
} catch (e) {
console.error('[ChessOverlay] Failed to initialize chess:', e);
}
}
function handleMatchEvent(type, data) {
if (isDestroyed) return;
console.log('[Chess] handleMatchEvent:', type, 'op_code:', data?.op_code);
if (type === 'disconnect') {
gamesOverlay.setMode('finished');
gamesOverlay.updateState({ result: 'disconnect', reason: 'Disconnected from server' });
return;
}
if (type !== 'matchdata') return;
try {
const payload = JSON.parse(new TextDecoder().decode(data.data));
// Convert op_code to number for safe comparison (Nakama SDK uses snake_case)
const opCode = Number(data.op_code);
console.log('[Chess] Received op_code:', opCode, 'payload:', payload);
if (opCode === ChessOpCode.GAME_STATE) {
console.log('[Chess] Processing GAME_STATE with status:', payload.status);
handleGameState(payload);
} else if (opCode === ChessOpCode.MOVE) {
handleOpponentMove(payload);
} else if (opCode === ChessOpCode.GAME_OVER) {
handleGameOver(payload);
} else {
console.log('[Chess] Unknown opCode:', opCode);
}
} catch (e) {
console.error('Error handling match event:', e, data);
}
}
function handleGameState(payload) {
if (payload.status === 'waiting') {
myColor = payload.yourColor;
gamesOverlay.setMode('waiting');
gamesOverlay.updateState({
positionId: payload.positionId,
whiteName: payload.whiteName,
blackName: payload.blackName
});
} else if (payload.status === 'playing') {
const session = nakama.getSession();
myColor = payload.whiteId === session?.user_id ? 'w' : 'b';
if (game) {
game.load(payload.fen);
}
gamesOverlay.setMode('playing');
gamesOverlay.updateState({
positionId: payload.positionId,
fen: payload.fen,
turn: payload.turn || game?.turn(),
whiteId: payload.whiteId,
blackId: payload.blackId,
whiteName: payload.whiteName,
blackName: payload.blackName,
myColor
});
updateBoardDisplay();
} else if (payload.status === 'spectating') {
myColor = null; // Spectator has no color
if (game) {
game.load(payload.fen);
}
gamesOverlay.setMode('spectating');
gamesOverlay.updateState({
positionId: payload.positionId,
fen: payload.fen,
turn: payload.turn || game?.turn(),
whiteId: payload.whiteId,
blackId: payload.blackId,
whiteName: payload.whiteName,
blackName: payload.blackName
});
updateBoardDisplay();
}
}
function handleOpponentMove(payload) {
if (game) {
game.load(payload.fen);
}
moveHistory = [...moveHistory, payload.move];
gamesOverlay.updateState({
fen: payload.fen,
turn: game?.turn()
});
updateBoardDisplay();
}
function handleGameOver(payload) {
gamesOverlay.setMode('finished');
gamesOverlay.updateState({
result: payload.result,
reason: payload.reason
});
}
function updateBoardDisplay() {
if (!game) return;
const board2d = [];
const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
// Spectators see from white's perspective, players see from their own side
const viewColor = myColor || 'w';
const ranks = viewColor === 'b' ? ['1', '2', '3', '4', '5', '6', '7', '8'] : ['8', '7', '6', '5', '4', '3', '2', '1'];
for (const rank of ranks) {
const row = [];
for (const file of files) {
const square = file + rank;
const piece = game.get(square);
row.push({
square,
piece: piece ? getPieceSymbol(piece) : null,
color: piece?.color || null,
isLight: (files.indexOf(file) + parseInt(rank)) % 2 === 1
});
}
board2d.push(row);
}
boardSquares = board2d;
}
function getPieceSymbol(piece) {
const symbols = {
'wk': '\u2654', 'wq': '\u2655', 'wr': '\u2656', 'wb': '\u2657', 'wn': '\u2658', 'wp': '\u2659',
'bk': '\u265A', 'bq': '\u265B', 'br': '\u265C', 'bb': '\u265D', 'bn': '\u265E', 'bp': '\u265F'
};
return symbols[piece.color + piece.type] || '';
}
function handleSquareClick(square) {
if ($gameMode !== 'playing') return;
if (!game || game.turn() !== myColor) return;
const piece = game.get(square);
if (selectedSquare) {
if (legalMoves.includes(square)) {
makeMove(selectedSquare, square);
}
selectedSquare = null;
legalMoves = [];
} else if (piece && piece.color === myColor) {
selectedSquare = square;
const moves = game.moves({ square, verbose: true });
legalMoves = moves.map(m => m.to);
}
updateBoardDisplay();
}
async function makeMove(from, to) {
if (!game) return;
const piece = game.get(from);
const isPromotion = piece.type === 'p' &&
((piece.color === 'w' && to[1] === '8') || (piece.color === 'b' && to[1] === '1'));
const moveData = { from, to };
if (isPromotion) {
moveData.promotion = 'q';
}
const result = game.move(moveData);
if (!result) {
console.error('Invalid move');
return;
}
await nakama.sendMatchData($gamesOverlay.matchId, ChessOpCode.MOVE, moveData);
moveHistory = [...moveHistory, result];
gamesOverlay.updateState({
fen: game.fen(),
turn: game.turn()
});
updateBoardDisplay();
}
async function resign() {
if ($gameMode !== 'playing') return;
if (confirm('Are you sure you want to resign?')) {
await nakama.sendMatchData($gamesOverlay.matchId, ChessOpCode.RESIGN, {});
}
}
function copyInviteLink() {
const url = `${window.location.origin}/games/chess?match=${$gamesOverlay.matchId}`;
navigator.clipboard.writeText(url).then(() => {
alert('Invite link copied!');
});
}
function handleClose() {
if ($gamesOverlay.matchId) {
nakama.leaveMatch($gamesOverlay.matchId);
}
if (unsubscribeMatch) {
unsubscribeMatch();
unsubscribeMatch = null;
}
game = null;
boardSquares = [];
selectedSquare = null;
legalMoves = [];
moveHistory = [];
myColor = null;
gamesOverlay.closeGame();
}
function handleMinimize() {
gamesOverlay.toggleMinimized();
}
// Drag handling
function startDrag(e) {
if (e.target.closest('button')) return;
isDragging = true;
const rect = e.currentTarget.closest('.chess-overlay').getBoundingClientRect();
dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
e.preventDefault();
}
function handleMouseMove(e) {
if (isDragging) {
position = {
x: e.clientX - dragOffset.x,
y: window.innerHeight - (e.clientY - dragOffset.y) - dimensions.height
};
} else if (isResizing) {
const dx = e.clientX - startPos.x;
const dy = e.clientY - startPos.y;
let newWidth = startDimensions.width;
let newHeight = startDimensions.height;
if (resizeDirection.includes('e')) {
newWidth = Math.max(400, startDimensions.width + dx);
}
if (resizeDirection.includes('w')) {
newWidth = Math.max(400, startDimensions.width - dx);
}
if (resizeDirection.includes('s')) {
newHeight = Math.max(450, startDimensions.height + dy);
}
if (resizeDirection.includes('n')) {
newHeight = Math.max(450, startDimensions.height - dy);
}
dimensions = { width: newWidth, height: newHeight };
}
}
function stopDrag() {
if (isDragging || isResizing) {
savePosition();
}
isDragging = false;
isResizing = false;
resizeDirection = '';
}
function startResize(e, direction) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
resizeDirection = direction;
startPos = { x: e.clientX, y: e.clientY };
startDimensions = { ...dimensions };
}
// Watch for overlay changes
let currentMatchId = null;
$: if (browser && $gamesOverlay.enabled && $gamesOverlay.matchId && $gamesOverlay.matchId !== currentMatchId) {
console.log('[ChessOverlay] Reactive: matchId changed from', currentMatchId, 'to', $gamesOverlay.matchId);
currentMatchId = $gamesOverlay.matchId;
initChess();
}
// Cleanup when game is closed
$: if (!$hasGame) {
currentMatchId = null;
if (unsubscribeMatch) {
unsubscribeMatch();
unsubscribeMatch = null;
}
game = null;
boardSquares = [];
moveHistory = [];
myColor = null;
}
onMount(() => {
loadPosition();
if (browser) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', stopDrag);
if ($gamesOverlay.enabled && $gamesOverlay.matchId) {
currentMatchId = $gamesOverlay.matchId;
initChess();
}
}
});
onDestroy(() => {
isDestroyed = true;
if (unsubscribeMatch) {
unsubscribeMatch();
}
if (browser) {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', stopDrag);
}
});
// Computed values for display
$: statusMessage = (() => {
switch ($gameMode) {
case 'waiting': return 'Waiting for opponent...';
case 'playing':
if (!game) return 'Loading...';
return game.turn() === myColor ? 'Your turn' : "Opponent's turn";
case 'spectating':
if (!$currentGame) return 'Spectating...';
return `${$currentGame.turn === 'w' ? 'White' : 'Black'} to move`;
case 'finished':
return getResultText();
default: return 'Connecting...';
}
})();
function getResultText() {
if (!$currentGame) return 'Game over';
const result = $currentGame.result;
const reason = $currentGame.reason || '';
if ($gameMode === 'spectating') {
if (result === '1-0') return `White wins${reason ? ` (${reason})` : ''}`;
if (result === '0-1') return `Black wins${reason ? ` (${reason})` : ''}`;
return `Draw${reason ? ` (${reason})` : ''}`;
}
if (result === '1-0') {
return myColor === 'w' ? 'You win!' : 'You lose';
} else if (result === '0-1') {
return myColor === 'b' ? 'You win!' : 'You lose';
} else if (result === 'timeout') {
return reason || 'Match timed out';
}
return `Draw${reason ? ` (${reason})` : ''}`;
}
$: headerTitle = (() => {
if (!$currentGame) return 'Chess960';
const white = $currentGame.whiteName || 'Waiting...';
const black = $currentGame.blackName || 'Waiting...';
return `${white} vs ${black}`;
})();
$: positionLabel = $currentGame?.positionId !== undefined
? `#${$currentGame.positionId}`
: '';
</script>
{#if $gamesOverlay.enabled && $gamesOverlay.matchId}
<div
class="chess-overlay"
class:minimized={$gamesOverlay.minimized}
style="
{computedPosition.right !== 'auto' ? `right: ${computedPosition.right};` : ''}
{computedPosition.top !== 'auto' ? `top: ${computedPosition.top};` : ''}
{computedPosition.left !== 'auto' ? `left: ${computedPosition.left};` : ''}
{computedPosition.bottom !== 'auto' ? `bottom: ${computedPosition.bottom};` : ''}
{computedPosition.transform !== 'none' ? `transform: ${computedPosition.transform};` : ''}
width: {dimensions.width}px;
{!$gamesOverlay.minimized ? `height: ${dimensions.height}px;` : ''}
"
>
<div class="overlay-header" on:mousedown={startDrag}>
<div class="header-left">
<span class="header-icon"></span>
<div class="header-info">
<span class="header-title">{headerTitle}</span>
{#if positionLabel}
<span class="header-meta">Position {positionLabel}</span>
{/if}
</div>
</div>
<div class="header-controls">
{#if $gameMode === 'spectating'}
<span class="mode-badge spectator">Spectating</span>
{/if}
<button class="control-btn" on:click={handleMinimize} title={$gamesOverlay.minimized ? 'Expand' : 'Minimize'}>
{$gamesOverlay.minimized ? '□' : ''}
</button>
<button class="control-btn close" on:click={handleClose} title="Close">×</button>
</div>
</div>
{#if !$gamesOverlay.minimized}
<div class="overlay-body">
<!-- Status Bar -->
<div class="status-bar" class:your-turn={$gameMode === 'playing' && game?.turn() === myColor}>
{#if $gameMode === 'playing' && myColor}
<span class="color-indicator" class:white={myColor === 'w'} class:black={myColor === 'b'}>
{myColor === 'w' ? 'White' : 'Black'}
</span>
{/if}
<span class="status-text">{statusMessage}</span>
</div>
<!-- Chess Board -->
<div class="board-wrapper">
<div class="chess-board">
{#each boardSquares as row}
<div class="board-row">
{#each row as cell}
<div
class="board-cell"
class:light={cell.isLight}
class:dark={!cell.isLight}
class:selected={selectedSquare === cell.square}
class:legal-move={legalMoves.includes(cell.square)}
class:clickable={$gameMode === 'playing'}
style="width: {cellSize}px; height: {cellSize}px;"
on:click={() => handleSquareClick(cell.square)}
>
{#if cell.piece}
<span class="piece" style="font-size: {cellSize * 0.7}px;" class:white-piece={cell.color === 'w'} class:black-piece={cell.color === 'b'}>
{cell.piece}
</span>
{/if}
</div>
{/each}
</div>
{/each}
</div>
</div>
</div>
<div class="overlay-footer">
{#if $gameMode === 'waiting'}
<button class="action-btn" on:click={copyInviteLink}>Copy Invite Link</button>
{:else if $gameMode === 'playing'}
<button class="action-btn danger" on:click={resign}>Resign</button>
{:else if $gameMode === 'finished'}
<button class="action-btn" on:click={handleClose}>Close</button>
{/if}
<div class="move-count">
{moveHistory.length} moves
</div>
</div>
<!-- Resize handles -->
<div class="resize-handle resize-e" on:mousedown={(e) => startResize(e, 'e')}></div>
<div class="resize-handle resize-s" on:mousedown={(e) => startResize(e, 's')}></div>
<div class="resize-handle resize-se" on:mousedown={(e) => startResize(e, 'se')}></div>
<div class="resize-handle resize-w" on:mousedown={(e) => startResize(e, 'w')}></div>
<div class="resize-handle resize-sw" on:mousedown={(e) => startResize(e, 'sw')}></div>
{/if}
</div>
{/if}
<style>
.chess-overlay {
position: fixed;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 9998;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
min-width: 400px;
min-height: 450px;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 2rem);
}
.chess-overlay.minimized {
height: auto !important;
min-height: auto;
}
.overlay-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #0d1117;
border-bottom: 1px solid #30363d;
cursor: move;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
flex: 1;
}
.header-icon {
font-size: 1.2rem;
color: #f59e0b;
}
.header-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.header-title {
color: #c9d1d9;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-meta {
color: #6e7681;
font-size: 0.65rem;
}
.header-controls {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.mode-badge {
font-size: 0.6rem;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-weight: 500;
}
.mode-badge.spectator {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
}
.control-btn {
width: 22px;
height: 22px;
border: none;
background: none;
color: #8b949e;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s ease;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #c9d1d9;
}
.control-btn.close:hover {
color: #f85149;
}
.overlay-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0.5rem;
gap: 0.5rem;
}
.status-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: #1a1a2e;
border-radius: 4px;
flex-shrink: 0;
}
.status-bar.your-turn {
background: #2a4a2a;
}
.color-indicator {
font-weight: bold;
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 3px;
}
.color-indicator.white {
background: #fff;
color: #000;
}
.color-indicator.black {
background: #333;
color: #fff;
}
.status-text {
color: #ccc;
font-size: 0.75rem;
}
.board-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.chess-board {
display: flex;
flex-direction: column;
border: 3px solid #333;
background: #333;
}
.board-row {
display: flex;
}
.board-cell {
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.board-cell.clickable {
cursor: pointer;
}
.board-cell.light {
background: #f0d9b5;
}
.board-cell.dark {
background: #b58863;
}
.board-cell.selected {
background: #7fc97f !important;
}
.board-cell.legal-move::after {
content: '';
position: absolute;
width: 30%;
height: 30%;
border-radius: 50%;
background: rgba(0, 0, 0, 0.2);
}
.board-cell.legal-move:hover {
background: #aad4aa !important;
}
.piece {
font-size: 2rem;
line-height: 1;
user-select: none;
}
.white-piece {
color: #fff;
text-shadow: 0 0 2px #000;
}
.black-piece {
color: #000;
text-shadow: 0 0 2px #fff;
}
.overlay-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: #0d1117;
border-top: 1px solid #30363d;
flex-shrink: 0;
}
.action-btn {
padding: 0.4rem 0.8rem;
border: 1px solid #30363d;
background: #161b22;
color: #c9d1d9;
font-size: 0.7rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
}
.action-btn:hover {
background: #21262d;
border-color: #484f58;
}
.action-btn.danger {
border-color: rgba(248, 81, 73, 0.4);
color: #f85149;
}
.action-btn.danger:hover {
background: rgba(248, 81, 73, 0.15);
border-color: #f85149;
}
.move-count {
color: #6e7681;
font-size: 0.65rem;
}
/* Resize handles */
.resize-handle {
position: absolute;
background: transparent;
}
.resize-e {
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: ew-resize;
}
.resize-w {
top: 0;
left: 0;
width: 6px;
height: 100%;
cursor: ew-resize;
}
.resize-s {
bottom: 0;
left: 0;
width: 100%;
height: 6px;
cursor: ns-resize;
}
.resize-se {
bottom: 0;
right: 0;
width: 12px;
height: 12px;
cursor: se-resize;
}
.resize-sw {
bottom: 0;
left: 0;
width: 12px;
height: 12px;
cursor: sw-resize;
}
.resize-handle:hover {
background: rgba(245, 158, 11, 0.3);
}
@media (max-width: 500px) {
.chess-overlay {
right: 0.5rem !important;
left: 0.5rem !important;
top: auto !important;
bottom: 0.5rem !important;
width: auto !important;
transform: none !important;
max-width: none;
}
.board-cell {
width: 36px;
height: 36px;
}
.piece {
font-size: 1.7rem;
}
.resize-handle {
display: none;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,112 @@
<script>
import { browser } from '$app/environment';
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
let audioElement;
// Track the current source to detect changes
let currentSrc = '';
// Audio playback handlers
function handleTimeUpdate() {
if (audioElement) {
audioPlaylist.updateTime(audioElement.currentTime, audioElement.duration);
}
}
function handleAudioEnded() {
const playlist = $audioPlaylist;
if (playlist.repeat === 'one') {
audioElement.currentTime = 0;
audioElement.play().then(() => {
audioPlaylist.confirmPlaying();
}).catch(() => {});
} else if (playlist.nowPlaying) {
// NowPlaying track ended - go to queue or stop
if (playlist.queue.length > 0) {
audioPlaylist.next();
} else {
audioPlaylist.clearNowPlaying();
audioPlaylist.setPlaying(false);
}
} else {
audioPlaylist.next();
}
}
// Handle shouldPlay signal - play when audio is ready
function handleCanPlay() {
if ($audioPlaylist.shouldPlay && audioElement) {
audioElement.play().then(() => {
audioPlaylist.confirmPlaying();
}).catch(e => {
console.error('Failed to play:', e);
});
}
}
// Handle audio errors
function handleError(e) {
console.error('Audio playback error:', e);
audioPlaylist.setPlaying(false);
}
// Watch for track changes - load new track when source changes
$: if (browser && audioElement && $currentTrack) {
const filePath = $currentTrack.filePath;
if (currentSrc !== filePath) {
currentSrc = filePath;
audioElement.src = filePath;
audioElement.load();
} else if ($audioPlaylist.shouldPlay && $audioPlaylist.currentTime === 0) {
// Same track but shouldPlay is true and time is 0 - restart from beginning
audioElement.currentTime = 0;
}
}
// Watch for shouldPlay changes - handles case where audio is already loaded
$: if (browser && audioElement && $audioPlaylist.shouldPlay) {
// If audio is ready (readyState >= 3 = HAVE_FUTURE_DATA), play immediately
if (audioElement.readyState >= 3) {
audioElement.play().then(() => {
audioPlaylist.confirmPlaying();
}).catch(e => {
console.error('Failed to play:', e);
});
}
}
// Watch for play/pause state changes
$: if (browser && audioElement) {
if ($audioPlaylist.isPlaying && audioElement.paused) {
audioElement.play().catch(() => {});
} else if (!$audioPlaylist.isPlaying && !audioElement.paused) {
audioElement.pause();
}
}
// Watch for volume changes
$: if (browser && audioElement) {
audioElement.volume = $audioPlaylist.muted ? 0 : $audioPlaylist.volume;
}
// Watch for seek (from external controls)
$: if (browser && audioElement && $currentTrack) {
const diff = Math.abs(audioElement.currentTime - $audioPlaylist.currentTime);
// Only seek if difference is significant (more than 1 second) to avoid feedback loops
if (diff > 1 && !audioElement.seeking) {
audioElement.currentTime = $audioPlaylist.currentTime;
}
}
</script>
<!-- Hidden audio element for global playback - persists across terminal open/close -->
<audio
bind:this={audioElement}
on:timeupdate={handleTimeUpdate}
on:ended={handleAudioEnded}
on:canplay={handleCanPlay}
on:error={handleError}
preload="auto"
style="display: none;"
></audio>

View file

@ -0,0 +1,871 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
export let initialData = null; // JSON pixel data for editing existing graffiti
export let existingUrl = ''; // URL of existing graffiti GIF
const dispatch = createEventDispatcher();
const WIDTH = 88;
const HEIGHT = 33;
const PIXEL_SIZE = 8; // Display size per pixel
let canvas;
let ctx;
let pixels = []; // 2D array of {r, g, b, a} or null (transparent)
let currentColor = { r: 255, g: 255, b: 255, a: 255 };
let colorHex = '#ffffff';
let isDrawing = false;
let tool = 'pencil'; // pencil, eraser, fill, eyedropper
let penSize = 1; // Brush width: 1=1x1, 2=2x2, 3=3x3, 5=5x5
let showGrid = true;
let hasChanges = false;
let saving = false;
// Brush sizes: 1x1=1px, 2x2=4px, 3x3=9px, 5x5=25px
const penSizes = [1, 2, 3, 5];
// Undo history (stores pixel array snapshots)
let history = [];
const MAX_HISTORY = 50;
let isStrokeActive = false;
// Color palette - 16 terminal colors (ANSI) + transparency
const palette = [
'#000000', '#aa0000', '#00aa00', '#aa5500', // 0-3: Black, Red, Green, Yellow/Brown
'#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa', // 4-7: Blue, Magenta, Cyan, White (light gray)
'#555555', '#ff5555', '#55ff55', '#ffff55', // 8-11: Bright Black, Bright Red, Bright Green, Bright Yellow
'#5555ff', '#ff55ff', '#55ffff', '#ffffff' // 12-15: Bright Blue, Bright Magenta, Bright Cyan, Bright White
];
onMount(() => {
initCanvas();
if (initialData) {
loadFromJson(initialData);
} else {
clearCanvas();
}
// Keyboard shortcuts
function handleKeyDown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
undo();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
});
function initCanvas() {
if (!canvas) return;
ctx = canvas.getContext('2d');
canvas.width = WIDTH * PIXEL_SIZE;
canvas.height = HEIGHT * PIXEL_SIZE;
}
function clearCanvas() {
pixels = Array(HEIGHT).fill(null).map(() =>
Array(WIDTH).fill(null)
);
redraw();
hasChanges = true;
}
function redraw() {
if (!ctx) return;
// Clear with transparent background
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw checkerboard background for transparency
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const isLight = (x + y) % 2 === 0;
ctx.fillStyle = isLight ? '#2a2a2a' : '#1a1a1a';
ctx.fillRect(x * PIXEL_SIZE, y * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE);
}
}
// Draw pixels
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const pixel = pixels[y]?.[x];
if (pixel) {
ctx.fillStyle = `rgba(${pixel.r}, ${pixel.g}, ${pixel.b}, ${pixel.a / 255})`;
ctx.fillRect(x * PIXEL_SIZE, y * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE);
}
}
}
// Draw grid (on top of everything)
if (showGrid) {
ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)';
ctx.lineWidth = 1;
for (let x = 0; x <= WIDTH; x++) {
ctx.beginPath();
ctx.moveTo(x * PIXEL_SIZE, 0);
ctx.lineTo(x * PIXEL_SIZE, HEIGHT * PIXEL_SIZE);
ctx.stroke();
}
for (let y = 0; y <= HEIGHT; y++) {
ctx.beginPath();
ctx.moveTo(0, y * PIXEL_SIZE);
ctx.lineTo(WIDTH * PIXEL_SIZE, y * PIXEL_SIZE);
ctx.stroke();
}
}
}
function getPixelCoords(e) {
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / PIXEL_SIZE);
const y = Math.floor((e.clientY - rect.top) / PIXEL_SIZE);
return { x: Math.max(0, Math.min(WIDTH - 1, x)), y: Math.max(0, Math.min(HEIGHT - 1, y)) };
}
// Deep clone the pixel array for undo history
function clonePixels() {
return pixels.map(row => row.map(p => p ? { ...p } : null));
}
// Save current state to history (call before making changes)
function saveToHistory() {
history.push(clonePixels());
if (history.length > MAX_HISTORY) {
history.shift(); // Remove oldest
}
}
// Undo last stroke
function undo() {
if (history.length === 0) return;
pixels = history.pop();
redraw();
hasChanges = history.length > 0 || pixels.some(row => row.some(p => p !== null));
}
function handleMouseDown(e) {
// Save state at start of stroke
if (!isStrokeActive) {
saveToHistory();
isStrokeActive = true;
}
isDrawing = true;
handleDraw(e);
}
function handleMouseMove(e) {
if (!isDrawing) return;
handleDraw(e);
}
function handleMouseUp() {
isDrawing = false;
isStrokeActive = false;
}
function drawPixelsInRadius(centerX, centerY, value) {
// Draw a square brush of penSize x penSize pixels
// Offset so brush is centered on cursor
const offset = Math.floor(penSize / 2);
for (let dy = 0; dy < penSize; dy++) {
for (let dx = 0; dx < penSize; dx++) {
const px = centerX - offset + dx;
const py = centerY - offset + dy;
// Check bounds
if (px >= 0 && px < WIDTH && py >= 0 && py < HEIGHT) {
pixels[py][px] = value;
}
}
}
}
function handleDraw(e) {
const { x, y } = getPixelCoords(e);
if (tool === 'pencil') {
drawPixelsInRadius(x, y, { ...currentColor });
hasChanges = true;
} else if (tool === 'eraser') {
drawPixelsInRadius(x, y, null);
hasChanges = true;
} else if (tool === 'fill') {
// Save history before fill (it's a single action, not a stroke)
saveToHistory();
floodFill(x, y);
hasChanges = true;
} else if (tool === 'eyedropper') {
const pixel = pixels[y]?.[x];
if (pixel) {
currentColor = { ...pixel };
colorHex = rgbToHex(pixel.r, pixel.g, pixel.b);
}
tool = 'pencil'; // Switch back to pencil after picking
// Don't save history for eyedropper - it doesn't modify pixels
return;
}
redraw();
}
function floodFill(startX, startY) {
const targetPixel = pixels[startY]?.[startX];
const targetKey = targetPixel ? `${targetPixel.r},${targetPixel.g},${targetPixel.b},${targetPixel.a}` : 'null';
const fillKey = `${currentColor.r},${currentColor.g},${currentColor.b},${currentColor.a}`;
if (targetKey === fillKey) return;
const stack = [[startX, startY]];
const visited = new Set();
while (stack.length > 0) {
const [x, y] = stack.pop();
const key = `${x},${y}`;
if (visited.has(key)) continue;
if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) continue;
const pixel = pixels[y]?.[x];
const pixelKey = pixel ? `${pixel.r},${pixel.g},${pixel.b},${pixel.a}` : 'null';
if (pixelKey !== targetKey) continue;
visited.add(key);
pixels[y][x] = { ...currentColor };
stack.push([x + 1, y]);
stack.push([x - 1, y]);
stack.push([x, y + 1]);
stack.push([x, y - 1]);
}
}
function rgbToHex(r, g, b) {
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
a: 255
} : null;
}
function handleColorChange(e) {
colorHex = e.target.value;
const rgb = hexToRgb(colorHex);
if (rgb) {
currentColor = rgb;
}
}
function selectPaletteColor(hex) {
colorHex = hex;
const rgb = hexToRgb(hex);
if (rgb) {
currentColor = rgb;
}
}
function exportJson() {
const data = {
version: 1,
width: WIDTH,
height: HEIGHT,
pixels: pixels
};
const json = JSON.stringify(data);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'graffiti.json';
a.click();
URL.revokeObjectURL(url);
}
function importJson(e) {
const file = e.target.files[0];
if (!file) return;
// Security: Limit file size to 100KB
if (file.size > 100 * 1024) {
alert('File too large (max 100KB)');
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target.result);
if (!loadFromJson(data)) {
alert('Invalid graffiti file format');
}
} catch (err) {
alert('Invalid graffiti file');
}
};
reader.readAsText(file);
e.target.value = '';
}
// Sanitize a single pixel value - prevent prototype pollution and validate structure
function sanitizePixel(pixel) {
if (pixel === null || pixel === undefined) {
return null;
}
// Must be a plain object with r, g, b, a properties
if (typeof pixel !== 'object' || Array.isArray(pixel)) {
return null;
}
// Explicitly extract and validate color values (prevents prototype pollution)
const r = Number(pixel.r);
const g = Number(pixel.g);
const b = Number(pixel.b);
const a = Number(pixel.a);
// Validate ranges (0-255)
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) {
return null;
}
return {
r: Math.max(0, Math.min(255, Math.floor(r))),
g: Math.max(0, Math.min(255, Math.floor(g))),
b: Math.max(0, Math.min(255, Math.floor(b))),
a: Math.max(0, Math.min(255, Math.floor(a)))
};
}
function loadFromJson(data) {
// Validate version
if (data.version !== 1) {
return false;
}
// Validate pixels array exists and is an array
if (!Array.isArray(data.pixels)) {
return false;
}
// Validate dimensions
if (data.pixels.length !== HEIGHT) {
return false;
}
// Sanitize and validate each row
const sanitizedPixels = [];
for (let y = 0; y < HEIGHT; y++) {
const row = data.pixels[y];
if (!Array.isArray(row) || row.length !== WIDTH) {
return false;
}
const sanitizedRow = [];
for (let x = 0; x < WIDTH; x++) {
sanitizedRow.push(sanitizePixel(row[x]));
}
sanitizedPixels.push(sanitizedRow);
}
pixels = sanitizedPixels;
redraw();
hasChanges = true;
return true;
}
// Generate GIF using a simple GIF encoder
async function generateGif() {
// Create an offscreen canvas at actual size
const offCanvas = document.createElement('canvas');
offCanvas.width = WIDTH;
offCanvas.height = HEIGHT;
const offCtx = offCanvas.getContext('2d');
// Draw pixels at 1:1 scale
const imageData = offCtx.createImageData(WIDTH, HEIGHT);
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const idx = (y * WIDTH + x) * 4;
const pixel = pixels[y]?.[x];
if (pixel) {
imageData.data[idx] = pixel.r;
imageData.data[idx + 1] = pixel.g;
imageData.data[idx + 2] = pixel.b;
imageData.data[idx + 3] = pixel.a;
} else {
imageData.data[idx] = 0;
imageData.data[idx + 1] = 0;
imageData.data[idx + 2] = 0;
imageData.data[idx + 3] = 0;
}
}
}
offCtx.putImageData(imageData, 0, 0);
// Convert to PNG blob (GIF requires external library, using PNG for now)
return new Promise((resolve) => {
offCanvas.toBlob((blob) => {
resolve(blob);
}, 'image/png');
});
}
async function saveGraffiti() {
saving = true;
try {
const blob = await generateGif();
const formData = new FormData();
formData.append('graffiti', blob, 'graffiti.png');
const response = await fetch('/api/user/graffiti', {
method: 'POST',
credentials: 'include',
body: formData
});
const data = await response.json();
if (data.success) {
hasChanges = false;
dispatch('save', { url: data.graffitiUrl });
} else {
alert(data.error || 'Failed to save graffiti');
}
} catch (err) {
console.error('Failed to save graffiti:', err);
alert('Failed to save graffiti');
} finally {
saving = false;
}
}
async function deleteGraffiti() {
if (!confirm('Are you sure you want to delete your graffiti?')) return;
saving = true;
try {
const response = await fetch('/api/user/graffiti', {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (data.success) {
clearCanvas();
hasChanges = false;
dispatch('delete');
} else {
alert(data.error || 'Failed to delete graffiti');
}
} catch (err) {
console.error('Failed to delete graffiti:', err);
alert('Failed to delete graffiti');
} finally {
saving = false;
}
}
</script>
<div class="graffiti-editor">
<div class="toolbar">
<div class="tool-group">
<button
class="tool-btn"
class:active={tool === 'pencil'}
on:click={() => tool = 'pencil'}
title="Pencil (P)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 19l7-7 3 3-7 7-3-3z"/>
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
<path d="M2 2l7.586 7.586"/>
</svg>
</button>
<button
class="tool-btn"
class:active={tool === 'eraser'}
on:click={() => tool = 'eraser'}
title="Eraser (E)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 20H7L3 16a1 1 0 0 1 0-1.41l11-11a1 1 0 0 1 1.41 0l5 5a1 1 0 0 1 0 1.41L9 22"/>
</svg>
</button>
<button
class="tool-btn"
class:active={tool === 'fill'}
on:click={() => tool = 'fill'}
title="Fill (F)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2L5 12l6 6 3-3"/>
<path d="M2 22l5-5"/>
<path d="M5.5 12.5L11 18"/>
<path d="M19 22c1.5 0 3-1 3-2.5s-1.5-2.5-3-4.5c-1.5 2-3 3-3 4.5s1.5 2.5 3 2.5z" fill="currentColor"/>
</svg>
</button>
<button
class="tool-btn"
class:active={tool === 'eyedropper'}
on:click={() => tool = 'eyedropper'}
title="Eyedropper (I)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 22l1-1h3l9-9"/>
<path d="M3 21v-3l9-9"/>
<circle cx="17" cy="7" r="4"/>
</svg>
</button>
</div>
<div class="tool-group pen-sizes">
<span class="pen-label">Size:</span>
{#each penSizes as size}
<button
class="pen-size-btn"
class:active={penSize === size}
on:click={() => penSize = size}
title="{size}x{size} ({size * size}px)"
>
<span class="pen-dot" style="width: {4 + size * 3}px; height: {4 + size * 3}px;"></span>
</button>
{/each}
</div>
<div class="tool-group">
<label class="grid-toggle">
<input type="checkbox" bind:checked={showGrid} on:change={redraw} />
Grid
</label>
</div>
<div class="tool-group">
<button class="tool-btn" on:click={undo} disabled={history.length === 0} title="Undo (Ctrl+Z)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7v6h6"/>
<path d="M3 13a9 9 0 1 0 3-7.5L3 7"/>
</svg>
</button>
</div>
</div>
<div class="canvas-container">
<canvas
bind:this={canvas}
on:mousedown={handleMouseDown}
on:mousemove={handleMouseMove}
on:mouseup={handleMouseUp}
on:mouseleave={handleMouseUp}
></canvas>
<div class="canvas-size">{WIDTH}x{HEIGHT}</div>
</div>
<div class="color-section">
<div class="current-color">
<div class="color-swatch" style="background: {colorHex};"></div>
<input
type="color"
value={colorHex}
on:input={handleColorChange}
title="Pick custom color"
/>
</div>
<div class="palette">
{#each palette as color}
<button
class="palette-color"
class:selected={colorHex === color}
style="background: {color};"
on:click={() => selectPaletteColor(color)}
></button>
{/each}
<button
class="palette-color transparent-btn"
class:selected={currentColor.a === 0}
on:click={() => { currentColor = { r: 0, g: 0, b: 0, a: 0 }; }}
title="Transparent"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="4" x2="20" y2="20"/>
</svg>
</button>
</div>
</div>
<div class="actions">
<div class="file-actions">
<button class="btn btn-secondary" on:click={exportJson}>
Export JSON
</button>
<label class="btn btn-secondary import-btn">
Import JSON
<input type="file" accept=".json" on:change={importJson} />
</label>
</div>
<div class="save-actions">
{#if existingUrl}
<button class="btn btn-danger" on:click={deleteGraffiti} disabled={saving}>
Delete
</button>
{/if}
<button class="btn btn-primary" on:click={saveGraffiti} disabled={saving || !hasChanges}>
{saving ? 'Saving...' : 'Save Graffiti'}
</button>
</div>
</div>
</div>
<style>
.graffiti-editor {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.toolbar {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.tool-group {
display: flex;
gap: 0.25rem;
align-items: center;
}
.tool-btn {
padding: 0.5rem;
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 4px;
color: var(--text, #fff);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
transition: all 0.15s;
}
.tool-btn:hover {
background: var(--bg-hover, #2a2a2a);
}
.tool-btn.active {
background: var(--primary, #561D5E);
border-color: var(--primary, #561D5E);
}
.tool-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tool-btn:disabled:hover {
background: var(--bg-secondary, #1a1a1a);
}
.grid-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--gray, #888);
cursor: pointer;
}
.canvas-container {
position: relative;
display: block;
width: fit-content;
border: 1px solid var(--border, #333);
border-radius: 4px;
overflow: hidden;
line-height: 0;
}
canvas {
display: block;
cursor: crosshair;
image-rendering: pixelated;
}
.canvas-size {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 0.75rem;
color: var(--gray, #888);
background: rgba(0, 0, 0, 0.7);
padding: 2px 6px;
border-radius: 2px;
}
.color-section {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.current-color {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-swatch {
width: 32px;
height: 32px;
border: 1px solid var(--border, #333);
border-radius: 4px;
}
.current-color input[type="color"] {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border, #333);
border-radius: 4px;
cursor: pointer;
}
.palette {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.palette-color {
width: 24px;
height: 24px;
border: 1px solid var(--border, #333);
border-radius: 2px;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.palette-color:hover {
transform: scale(1.1);
}
.palette-color.selected {
outline: 2px solid var(--primary, #561D5E);
outline-offset: 1px;
}
.transparent-btn {
background: repeating-conic-gradient(#333 0% 25%, #1a1a1a 0% 50%) 50% / 8px 8px;
}
.actions {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.file-actions, .save-actions {
display: flex;
gap: 0.5rem;
}
.import-btn {
cursor: pointer;
}
.import-btn input {
display: none;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--border, #333);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.15s;
}
.btn-secondary {
background: var(--bg-secondary, #1a1a1a);
color: var(--text, #fff);
}
.btn-secondary:hover {
background: var(--bg-hover, #2a2a2a);
}
.btn-primary {
background: var(--primary, #561D5E);
color: white;
border-color: var(--primary, #561D5E);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover, #6d2575);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
background: #a33;
color: white;
border-color: #a33;
}
.btn-danger:hover:not(:disabled) {
background: #c44;
}
.pen-sizes {
gap: 0.25rem;
}
.pen-label {
font-size: 0.75rem;
color: var(--gray, #888);
margin-right: 0.25rem;
}
.pen-size-btn {
width: 28px;
height: 28px;
padding: 0;
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.pen-size-btn:hover {
background: var(--bg-hover, #2a2a2a);
}
.pen-size-btn.active {
background: var(--primary, #561D5E);
border-color: var(--primary, #561D5E);
}
.pen-dot {
background: var(--text, #fff);
border-radius: 50%;
}
</style>

View file

@ -0,0 +1,485 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { streamTiles } from '$lib/stores/streamTiles';
/** @type {{ streamKey: string, name: string, username: string, realmId: number, offlineImageUrl?: string, muted?: boolean, volume?: number }} */
export let stream;
/** @type {boolean} Whether to show close button */
export let showClose = true;
/** @type {boolean} Whether this is the main stream (not a tile) */
export let isMain = false;
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
let player;
let playerElement;
let viewerToken = null;
let actualStreamKey = null; // The real stream key (not realm name)
let isLive = false;
let loading = true;
let error = '';
let playerId = `player-${stream.realmId}-${Math.random().toString(36).substr(2, 9)}`;
let showControls = false;
let showVolumeSlider = false;
// Get muted/volume from stream object with defaults
$: muted = stream.muted !== undefined ? stream.muted : true;
$: volume = stream.volume !== undefined ? stream.volume : 0.5;
onMount(async () => {
if (!browser) return;
// Wait for OvenPlayer to be available
const waitForPlayer = async () => {
let attempts = 0;
while (!window.OvenPlayer && attempts < 30) {
await new Promise(r => setTimeout(r, 100));
attempts++;
}
return !!window.OvenPlayer;
};
const playerReady = await waitForPlayer();
if (!playerReady) {
error = 'Player not available';
loading = false;
return;
}
// Get viewer token first
await getViewerToken();
// Then get stream key (requires valid viewer token)
if (viewerToken) {
await getStreamKey();
}
if (viewerToken && actualStreamKey) {
initializePlayer();
} else {
error = 'Could not get stream access';
}
loading = false;
});
onDestroy(() => {
if (player) {
try {
player.remove();
} catch (e) {
console.error('Error removing player:', e);
}
}
});
async function getViewerToken() {
try {
// Use realm ID to get viewer token (same as main player)
const response = await fetch(`/api/realms/${stream.realmId}/viewer-token`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
viewerToken = data.viewer_token;
} else {
console.error('Failed to get viewer token for tile:', stream.name, response.status);
}
} catch (e) {
console.error('Failed to get viewer token:', e);
}
}
async function getStreamKey() {
try {
// Get the actual stream key (requires valid viewer token cookie)
const response = await fetch(`/api/realms/${stream.realmId}/stream-key`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
actualStreamKey = data.stream_key;
} else {
console.error('Failed to get stream key for tile:', stream.name, response.status);
}
} catch (e) {
console.error('Failed to get stream key:', e);
}
}
function initializePlayer() {
if (!playerElement || !window.OvenPlayer || !viewerToken || !actualStreamKey) return;
const sources = [
{
type: 'hls',
file: `http://localhost:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${viewerToken}`,
label: 'LLHLS'
}
];
const config = {
autoStart: true,
autoFallback: true,
controls: false,
showBigPlayButton: false,
watermark: false,
mute: muted,
volume: volume * 100,
aspectRatio: "16:9",
sources: sources,
hlsConfig: {
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
xhrSetup: function(xhr, url) {
// Only add token if not already present (segments don't have it)
if (viewerToken && url.includes('/app/') && !url.includes('token=')) {
const separator = url.includes('?') ? '&' : '?';
xhr.open('GET', url + separator + 'token=' + viewerToken, true);
}
xhr.withCredentials = true;
}
}
};
try {
player = window.OvenPlayer.create(playerId, config);
player.on('stateChanged', (data) => {
if (data.newstate === 'playing') {
isLive = true;
} else if (data.newstate === 'idle' || data.newstate === 'error') {
isLive = false;
}
});
player.on('error', (err) => {
console.error('Tile player error:', err);
isLive = false;
});
} catch (e) {
console.error('Failed to create tile player:', e);
error = 'Failed to initialize player';
}
}
function handleClose() {
streamTiles.removeStream(stream.streamKey);
}
function handleMuteToggle() {
streamTiles.toggleMute(stream.streamKey);
}
function handleVolumeChange(e) {
const newVolume = parseFloat(e.target.value);
streamTiles.setVolume(stream.streamKey, newVolume);
// Unmute if adjusting volume while muted
if (muted && newVolume > 0) {
streamTiles.setMuted(stream.streamKey, false);
}
}
// Update player mute/volume when props change
$: if (player) {
try {
player.setMute(muted);
player.setVolume(volume * 100);
} catch (e) {}
}
</script>
<div
class="stream-tile"
class:main={isMain}
on:mouseenter={() => showControls = true}
on:mouseleave={() => { showControls = false; showVolumeSlider = false; }}
>
<div class="tile-player">
{#if loading}
<div class="tile-loading">Loading...</div>
{:else if error}
<div class="tile-error">{error}</div>
{:else}
<div id={playerId} bind:this={playerElement} class="player"></div>
{#if !isLive}
<div class="tile-offline">
{#if stream.offlineImageUrl}
<img src={stream.offlineImageUrl} alt="{stream.name} offline" />
{:else}
<div class="offline-placeholder">
<span class="offline-letter">{stream.name.charAt(0).toUpperCase()}</span>
<span class="offline-text">OFFLINE</span>
</div>
{/if}
</div>
{/if}
{/if}
</div>
<!-- Overlay controls - always visible when offline, hover when live -->
{#if showControls || !isLive}
<div class="tile-overlay" class:always-visible={!isLive}>
<!-- Top bar with title and close -->
<div class="overlay-top">
<a href="/{stream.name}/live" class="tile-name" target="_blank">{stream.name}</a>
{#if showClose}
<button class="overlay-btn close" on:click={handleClose} title="Remove"></button>
{/if}
</div>
<!-- Bottom bar with volume (only when live or hovering) -->
{#if isLive && showControls}
<div class="overlay-bottom">
<div class="volume-control"
on:mouseenter={() => showVolumeSlider = true}
on:mouseleave={() => showVolumeSlider = false}
>
<button class="overlay-btn" on:click={handleMuteToggle} title={muted ? 'Unmute' : 'Mute'}>
{#if muted || volume === 0}
🔇
{:else if volume < 0.5}
🔉
{:else}
🔊
{/if}
</button>
{#if showVolumeSlider}
<div class="volume-slider-container">
<input
type="range"
class="volume-slider"
min="0"
max="1"
step="0.05"
value={volume}
on:input={handleVolumeChange}
/>
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
.stream-tile {
position: relative;
display: flex;
flex-direction: column;
background: #000;
border-radius: 4px;
overflow: hidden;
flex: 1;
min-height: 0;
}
.stream-tile.main {
/* Main tile styling if needed */
}
.tile-player {
position: relative;
flex: 1;
min-height: 0;
background: #000;
}
.tile-player .player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.tile-loading,
.tile-error {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
color: #888;
font-size: 0.85rem;
}
.tile-error {
color: #f85149;
}
.tile-offline {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
background: #000;
}
.tile-offline img {
width: 100%;
height: 100%;
object-fit: cover;
}
.offline-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a1a, #0d0d0d);
}
.offline-letter {
font-size: 2rem;
font-weight: 600;
color: #444;
}
.offline-text {
font-size: 0.7rem;
color: #555;
letter-spacing: 0.1em;
margin-top: 0.25rem;
}
/* Overlay controls */
.tile-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.7) 0%,
transparent 30%,
transparent 70%,
rgba(0, 0, 0, 0.7) 100%
);
opacity: 0;
transition: opacity 0.2s ease;
}
.stream-tile:hover .tile-overlay,
.tile-overlay.always-visible {
opacity: 1;
}
.overlay-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
pointer-events: auto;
}
.overlay-bottom {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 0.5rem 0.75rem;
pointer-events: auto;
}
.tile-name {
color: #fff;
font-size: 0.85rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.tile-name:hover {
color: var(--primary, #8b5cf6);
}
.overlay-btn {
width: 28px;
height: 28px;
border: none;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
color: #fff;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
flex-shrink: 0;
}
.overlay-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.overlay-btn.close:hover {
background: rgba(248, 81, 73, 0.6);
color: #fff;
}
.volume-control {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.volume-slider-container {
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.6);
padding: 0.35rem 0.5rem;
border-radius: 4px;
}
.volume-slider {
width: 80px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
border: none;
}
</style>

View file

@ -0,0 +1,535 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { streamTiles } from '$lib/stores/streamTiles';
import { goto } from '$app/navigation';
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
let players = {};
let viewerTokens = {};
let offlineStreams = {}; // Track which streams are offline
let streamInfo = {}; // Cache stream info including offlineImageUrl
let statusCheckInterval = null;
let Hls;
let OvenPlayer;
$: tileCount = $streamTiles.streams.length;
$: gridClass = tileCount === 1 ? 'grid-1' : tileCount === 2 ? 'grid-2' : 'grid-4';
onMount(async () => {
if (!browser) return;
// Load player dependencies
try {
const hlsModule = await import('hls.js');
Hls = hlsModule.default;
window.Hls = Hls;
const ovenModule = await import('ovenplayer');
OvenPlayer = ovenModule.default;
window.OvenPlayer = OvenPlayer;
} catch (e) {
console.error('Failed to load player dependencies:', e);
}
// Start checking stream status
checkStreamStatus();
statusCheckInterval = setInterval(checkStreamStatus, 10000);
});
onDestroy(() => {
// Cleanup all players
Object.values(players).forEach(p => {
if (p) p.remove();
});
players = {};
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
}
});
async function checkStreamStatus() {
if (!browser || $streamTiles.streams.length === 0) return;
try {
const res = await fetch('/api/realms/live');
if (res.ok) {
const liveStreams = await res.json();
const liveStreamKeys = new Set(liveStreams.map(s => s.streamKey));
// Update offline status and cache stream info
liveStreams.forEach(s => {
streamInfo[s.streamKey] = {
offlineImageUrl: s.offlineImageUrl,
name: s.name,
username: s.username
};
});
// Check each tiled stream
$streamTiles.streams.forEach(stream => {
const wasOffline = offlineStreams[stream.streamKey];
const isOffline = !liveStreamKeys.has(stream.streamKey);
if (isOffline && !wasOffline) {
// Stream went offline - destroy player
if (players[stream.streamKey]) {
players[stream.streamKey].remove();
delete players[stream.streamKey];
}
offlineStreams[stream.streamKey] = true;
offlineStreams = { ...offlineStreams }; // Trigger reactivity
} else if (!isOffline && wasOffline) {
// Stream came back online - reinit player
delete offlineStreams[stream.streamKey];
offlineStreams = { ...offlineStreams }; // Trigger reactivity
const index = $streamTiles.streams.findIndex(s => s.streamKey === stream.streamKey);
if (index !== -1) {
initPlayer(stream, `tile-player-${index}`);
}
}
});
}
} catch (e) {
console.error('Failed to check stream status:', e);
}
}
async function getViewerToken(streamKey) {
if (viewerTokens[streamKey]) return viewerTokens[streamKey];
try {
const res = await fetch(`/api/stream/viewer-token/${streamKey}`, {
method: 'POST',
credentials: 'include'
});
if (res.ok) {
const data = await res.json();
viewerTokens[streamKey] = data.token;
return data.token;
}
} catch (e) {
console.error('Failed to get viewer token:', e);
}
return null;
}
async function initPlayer(stream, containerId) {
if (!browser || !window.OvenPlayer || !window.Hls) return;
const token = await getViewerToken(stream.streamKey);
if (!token) {
console.error('Failed to get viewer token for', stream.streamKey);
return;
}
const isMuted = $streamTiles.unmutedStream !== stream.streamKey;
const sources = [
{
type: 'hls',
file: `http://localhost:${STREAM_PORT}/app/${stream.streamKey}/llhls.m3u8?token=${token}`,
label: 'LLHLS'
}
];
const config = {
autoStart: true,
autoFallback: true,
controls: false,
showBigPlayButton: false,
watermark: false,
mute: isMuted,
aspectRatio: "16:9",
sources: sources,
hlsConfig: {
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 30,
xhrSetup: function(xhr, url) {
if (token && url.includes('/app/')) {
const separator = url.includes('?') ? '&' : '?';
xhr.open('GET', url + separator + 'token=' + token, true);
}
xhr.withCredentials = true;
}
}
};
try {
// Clean up existing player
if (players[stream.streamKey]) {
players[stream.streamKey].remove();
}
const playerEl = document.getElementById(containerId);
if (!playerEl) return;
const player = window.OvenPlayer.create(containerId, config);
players[stream.streamKey] = player;
player.on('ready', () => {
player.setMute(isMuted);
});
player.on('error', (error) => {
console.error('Tile player error:', error);
// Mark stream as offline on persistent errors
if (error.code === 404 || error.code === 403 || error.message?.includes('not found')) {
offlineStreams[stream.streamKey] = true;
offlineStreams = { ...offlineStreams };
if (players[stream.streamKey]) {
players[stream.streamKey].remove();
delete players[stream.streamKey];
}
}
});
} catch (e) {
console.error('Failed to create player:', e);
}
}
function handleToggleMute(streamKey) {
streamTiles.toggleMute(streamKey);
// Update mute state for all players
Object.entries(players).forEach(([key, player]) => {
if (player) {
const shouldMute = key !== streamKey || $streamTiles.unmutedStream === streamKey;
player.setMute(shouldMute);
}
});
}
function handleRemoveStream(streamKey) {
if (players[streamKey]) {
players[streamKey].remove();
delete players[streamKey];
}
delete viewerTokens[streamKey];
streamTiles.removeStream(streamKey);
}
function handleGoToStream(stream) {
goto(`/${stream.name}/live`);
}
function handleClose() {
streamTiles.hide();
}
// Re-initialize players when streams change
$: if (browser && $streamTiles.enabled && window.OvenPlayer) {
// Small delay to ensure DOM is ready
setTimeout(() => {
$streamTiles.streams.forEach((stream, index) => {
const containerId = `tile-player-${index}`;
if (!players[stream.streamKey]) {
initPlayer(stream, containerId);
}
});
}, 100);
}
// Update mute states when unmutedStream changes
$: if (browser && $streamTiles.enabled) {
Object.entries(players).forEach(([key, player]) => {
if (player) {
const shouldMute = $streamTiles.unmutedStream !== key;
try {
player.setMute(shouldMute);
} catch (e) {}
}
});
}
</script>
{#if $streamTiles.enabled && $streamTiles.streams.length > 0}
<div class="tile-overlay">
<div class="tile-header">
<span class="tile-title">Stream Tiles ({$streamTiles.streams.length})</span>
<button class="close-btn" on:click={handleClose}>×</button>
</div>
<div class="tile-grid {gridClass}">
{#each $streamTiles.streams as stream, index (stream.streamKey)}
<div class="tile">
{#if offlineStreams[stream.streamKey]}
<div class="tile-offline">
{#if stream.offlineImageUrl || streamInfo[stream.streamKey]?.offlineImageUrl}
<img
src={stream.offlineImageUrl || streamInfo[stream.streamKey]?.offlineImageUrl}
alt="Stream offline"
class="offline-image"
/>
{:else}
<div class="offline-placeholder">
<div class="offline-initial">{stream.name.charAt(0).toUpperCase()}</div>
<span class="offline-text">Offline</span>
</div>
{/if}
</div>
{:else}
<div class="tile-player" id="tile-player-{index}"></div>
{/if}
<div class="tile-controls" class:always-visible={offlineStreams[stream.streamKey]}>
<span class="tile-name">
{stream.name}
{#if offlineStreams[stream.streamKey]}
<span class="offline-badge">OFFLINE</span>
{/if}
</span>
<div class="tile-buttons">
{#if !offlineStreams[stream.streamKey]}
<button
class="tile-control-btn"
class:unmuted={$streamTiles.unmutedStream === stream.streamKey}
on:click={() => handleToggleMute(stream.streamKey)}
title={$streamTiles.unmutedStream === stream.streamKey ? 'Mute' : 'Unmute'}
>
{$streamTiles.unmutedStream === stream.streamKey ? '🔊' : '🔇'}
</button>
{/if}
<button
class="tile-control-btn"
on:click={() => handleGoToStream(stream)}
title="Go to stream"
>
</button>
<button
class="tile-control-btn remove"
on:click={() => handleRemoveStream(stream.streamKey)}
title="Remove"
>
×
</button>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<style>
.tile-overlay {
position: fixed;
bottom: 1rem;
right: 1rem;
width: 400px;
max-width: calc(100vw - 2rem);
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 9999;
overflow: hidden;
}
.tile-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #0d1117;
border-bottom: 1px solid #30363d;
}
.tile-title {
color: #c9d1d9;
font-size: 0.8rem;
font-weight: 500;
}
.close-btn {
width: 24px;
height: 24px;
border: none;
background: none;
color: #8b949e;
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s ease;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f85149;
}
.tile-grid {
display: grid;
gap: 2px;
background: #30363d;
}
.tile-grid.grid-1 {
grid-template-columns: 1fr;
}
.tile-grid.grid-2 {
grid-template-columns: 1fr 1fr;
}
.tile-grid.grid-4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.tile {
position: relative;
background: #0d1117;
aspect-ratio: 16/9;
}
.tile-player {
width: 100%;
height: 100%;
}
.tile-player :global(video) {
width: 100%;
height: 100%;
object-fit: contain;
}
.tile-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
opacity: 0;
transition: opacity 0.2s ease;
}
.tile:hover .tile-controls {
opacity: 1;
}
.tile-name {
color: #c9d1d9;
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 50%;
}
.tile-buttons {
display: flex;
gap: 0.25rem;
}
.tile-control-btn {
width: 22px;
height: 22px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: #c9d1d9;
font-size: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: all 0.15s ease;
}
.tile-control-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.tile-control-btn.unmuted {
background: rgba(126, 231, 135, 0.2);
color: #7ee787;
}
.tile-control-btn.remove:hover {
background: rgba(248, 81, 73, 0.3);
color: #f85149;
}
/* Offline state styles */
.tile-offline {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #0d1117;
}
.offline-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.offline-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: linear-gradient(135deg, #1a1a2e, #16213e);
width: 100%;
height: 100%;
}
.offline-initial {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #561d5e, #8b3a92);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: 700;
color: white;
}
.offline-text {
color: #6e7681;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.tile-controls.always-visible {
opacity: 1;
background: rgba(0, 0, 0, 0.6);
}
.offline-badge {
background: #6e7681;
color: #0d1117;
font-size: 0.5rem;
padding: 0.1rem 0.3rem;
border-radius: 2px;
margin-left: 0.3rem;
font-weight: 600;
vertical-align: middle;
}
@media (max-width: 500px) {
.tile-overlay {
width: calc(100vw - 1rem);
right: 0.5rem;
bottom: 0.5rem;
}
}
</style>

View file

@ -0,0 +1,467 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { ubercoinBalance, previewTransaction, sendUbercoin, formatUbercoin } from '$lib/stores/ubercoin';
export let recipientUsername = '';
export let show = false;
const dispatch = createEventDispatcher();
let amount = '';
let preview = null;
let loading = false;
let previewLoading = false;
let error = '';
let success = false;
let panelElement;
let clickOutsideEnabled = false;
// Debounced preview
let previewTimeout;
$: if (show && amount && parseFloat(amount) > 0) {
clearTimeout(previewTimeout);
previewLoading = true;
previewTimeout = setTimeout(async () => {
preview = await previewTransaction(recipientUsername, parseFloat(amount));
previewLoading = false;
}, 300);
} else {
preview = null;
previewLoading = false;
}
// Reset state when panel opens
$: if (show) {
amount = '';
preview = null;
loading = false;
error = '';
success = false;
clickOutsideEnabled = false;
// Enable click-outside after a brief delay to prevent immediate closing
setTimeout(() => { clickOutsideEnabled = true; }, 100);
} else {
clickOutsideEnabled = false;
}
onMount(() => {
document.addEventListener('keydown', handleKeydown);
document.addEventListener('click', handleClickOutside);
});
onDestroy(() => {
document.removeEventListener('keydown', handleKeydown);
document.removeEventListener('click', handleClickOutside);
clearTimeout(previewTimeout);
});
function handleKeydown(event) {
if (!show) return;
if (event.key === 'Escape') {
handleClose();
}
}
function handleClickOutside(event) {
if (!show || loading || !clickOutsideEnabled) return;
if (panelElement && !panelElement.contains(event.target)) {
handleClose();
}
}
async function handleSend() {
const parsedAmount = parseFloat(amount);
if (!amount || parsedAmount <= 0) {
error = 'Enter a valid amount';
return;
}
if (parsedAmount > $ubercoinBalance) {
error = 'Insufficient balance';
return;
}
loading = true;
error = '';
const result = await sendUbercoin(recipientUsername, parsedAmount);
loading = false;
if (result.success) {
success = true;
setTimeout(() => {
dispatch('close');
dispatch('sent', result);
}, 1500);
} else {
error = result.error || 'Failed to send';
}
}
function handleClose() {
if (!loading) {
dispatch('close');
}
}
</script>
<style>
.tip-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: #1a1a1a;
border: 1px solid var(--border, #333);
border-radius: 8px;
width: 300px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: fadeIn 0.15s ease-out;
overflow: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -50%) translateY(-5px);
}
to {
opacity: 1;
transform: translate(-50%, -50%) translateY(0);
}
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border, #333);
background: rgba(255, 215, 0, 0.05);
}
.panel-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: white;
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border-radius: 50%;
font-size: 0.6rem;
font-weight: bold;
color: #000;
}
.close-btn {
background: transparent;
border: none;
color: #666;
font-size: 1.1rem;
cursor: pointer;
padding: 2px 6px;
line-height: 1;
border-radius: 4px;
transition: all 0.15s ease;
}
.close-btn:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
.panel-body {
padding: 14px;
}
.recipient-info {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
font-size: 0.85rem;
}
.recipient-info span {
color: #888;
}
.recipient-info strong {
color: white;
}
.balance-row {
display: flex;
align-items: center;
gap: 6px;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
margin-bottom: 12px;
font-size: 0.85rem;
color: #ccc;
}
.balance-row strong {
color: #ffd700;
}
.coin-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border-radius: 50%;
font-size: 0.6rem;
font-weight: bold;
color: #000;
}
.input-group {
margin-bottom: 12px;
}
.input-group label {
display: block;
font-size: 0.8rem;
color: #888;
margin-bottom: 4px;
}
.input-group input {
width: 100%;
padding: 8px 10px;
background: #222;
border: 1px solid var(--border, #333);
border-radius: 4px;
color: white;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s ease;
}
.input-group input:focus {
border-color: #ffd700;
}
.input-group input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.burn-info-box {
display: flex;
gap: 10px;
padding: 10px;
background: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.3);
border-radius: 6px;
margin-bottom: 12px;
}
.warning-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: #ff9800;
color: #000;
border-radius: 50%;
font-weight: bold;
font-size: 0.75rem;
flex-shrink: 0;
}
.burn-details {
flex: 1;
}
.burn-details strong {
display: block;
color: #ff9800;
font-size: 0.85rem;
margin-bottom: 2px;
}
.burn-details p {
margin: 0 0 2px 0;
font-size: 0.8rem;
color: #ccc;
}
.burn-details .detail-small {
font-size: 0.75rem;
color: #888;
}
.preview-loading {
display: flex;
align-items: center;
gap: 6px;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
margin-bottom: 12px;
font-size: 0.8rem;
color: #888;
}
.error-message {
padding: 8px 10px;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 4px;
color: #f44336;
font-size: 0.8rem;
margin-bottom: 12px;
}
.success-message {
padding: 8px 10px;
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 4px;
color: #4caf50;
font-size: 0.8rem;
margin-bottom: 12px;
text-align: center;
}
.panel-footer {
display: flex;
gap: 10px;
padding: 12px 14px;
border-top: 1px solid var(--border, #333);
}
.panel-footer button {
flex: 1;
padding: 8px 12px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.cancel-btn {
background: transparent;
border: 1px solid var(--border, #333);
color: white;
}
.cancel-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
.send-btn {
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border: none;
color: #000;
font-weight: 600;
}
.send-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.send-btn:disabled,
.cancel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
{#if show}
<div class="tip-panel" bind:this={panelElement} role="dialog" aria-modal="true">
<div class="panel-header">
<h3>
<span class="header-icon">Ü</span>
Send übercoin
</h3>
<button class="close-btn" on:click={handleClose} disabled={loading}>×</button>
</div>
<div class="panel-body">
<div class="recipient-info">
<span>To:</span>
<strong>{recipientUsername}</strong>
</div>
<div class="balance-row">
<span class="coin-icon">Ü</span>
<span>Balance: <strong>{formatUbercoin($ubercoinBalance)}</strong></span>
</div>
<div class="input-group">
<label for="tip-amount">Amount</label>
<input
type="number"
id="tip-amount"
bind:value={amount}
placeholder="0.000"
step="0.001"
min="0.001"
disabled={loading || success}
/>
</div>
{#if previewLoading}
<div class="preview-loading">
Calculating burn rate...
</div>
{:else if preview && preview.success && preview.burnRatePercent > 0}
<div class="burn-info-box">
<span class="warning-icon">!</span>
<div class="burn-details">
<strong>{preview.burnRatePercent.toFixed(1)}% burn rate</strong>
<p>{recipientUsername} receives <strong>{formatUbercoin(preview.receivedAmount)}</strong> UC</p>
<p class="detail-small">{formatUbercoin(preview.burnedAmount)} UC → Treasury ({preview.recipientAccountAgeDays}d old)</p>
</div>
</div>
{/if}
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if success}
<div class="success-message">Sent successfully!</div>
{/if}
</div>
<div class="panel-footer">
<button class="cancel-btn" on:click={handleClose} disabled={loading}>
Cancel
</button>
<button
class="send-btn"
on:click={handleSend}
disabled={loading || success || !amount || parseFloat(amount) <= 0}
>
{#if loading}
Sending...
{:else}
Send
{/if}
</button>
</div>
</div>
{/if}

View file

@ -0,0 +1,711 @@
<script>
import { createEventDispatcher, onMount, tick } from 'svelte';
import { stickerFavorites } from '$lib/chat/stickerFavorites';
import { stickers, stickersMap as sharedStickersMap, ensureLoaded } from '$lib/stores/stickers';
export let disabled = false;
export let username = '';
const dispatch = createEventDispatcher();
let message = '';
let maxLength = 500;
let selfDestructSeconds = 0; // 0 = permanent
let showTimerMenu = false;
let showFavoritesMenu = false;
let inputElement;
let favoriteContextMenu = null; // { stickerName, x, y }
// Autocomplete state
let showAutocomplete = false;
let autocompleteQuery = '';
let autocompleteSuggestions = [];
let selectedIndex = 0;
let lastSentMessage = '';
onMount(async () => {
// Ensure stickers are loaded (uses shared store - only fetches once across all components)
await ensureLoaded();
});
// Function to insert text at cursor or append to message
export function insertText(text) {
if (inputElement) {
const start = inputElement.selectionStart;
const end = inputElement.selectionEnd;
const before = message.slice(0, start);
const after = message.slice(end);
message = before + text + after;
// Focus and set cursor position after inserted text
setTimeout(() => {
inputElement.focus();
const newPos = start + text.length;
inputElement.setSelectionRange(newPos, newPos);
}, 0);
} else {
message += text;
}
}
const timerOptions = [
{ label: 'Off', value: 0 },
{ label: '5s', value: 5 },
{ label: '10s', value: 10 },
{ label: '30s', value: 30 },
{ label: '1m', value: 60 },
{ label: '5m', value: 300 }
];
// Autocomplete: detect :query pattern and filter stickers
function handleInput() {
if (!inputElement) return;
const cursorPos = inputElement.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last unmatched : before cursor
const lastColonIndex = textBeforeCursor.lastIndexOf(':');
if (lastColonIndex === -1) {
showAutocomplete = false;
return;
}
// Check if there's a closing : between the last : and cursor (means it's already complete)
const textAfterColon = textBeforeCursor.slice(lastColonIndex + 1);
if (textAfterColon.includes(':')) {
showAutocomplete = false;
return;
}
// Extract query (text after :)
const query = textAfterColon.toLowerCase();
// Only show autocomplete if there's at least 1 character after :
if (query.length < 1) {
showAutocomplete = false;
return;
}
autocompleteQuery = query;
updateAutocompleteSuggestions(query);
}
function updateAutocompleteSuggestions(query) {
if (!query || $stickers.length === 0) {
autocompleteSuggestions = [];
showAutocomplete = false;
return;
}
// Filter stickers that contain the query
const matches = $stickers.filter((s) =>
s.name.toLowerCase().includes(query)
);
// Sort: stickers starting with query first, then others
matches.sort((a, b) => {
const aStartsWith = a.name.toLowerCase().startsWith(query);
const bStartsWith = b.name.toLowerCase().startsWith(query);
if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1;
// Secondary sort: alphabetically
return a.name.localeCompare(b.name);
});
// Limit to 8 suggestions
autocompleteSuggestions = matches.slice(0, 8);
selectedIndex = 0;
showAutocomplete = autocompleteSuggestions.length > 0;
}
function selectAutocomplete(sticker) {
if (!inputElement) return;
const cursorPos = inputElement.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
const lastColonIndex = textBeforeCursor.lastIndexOf(':');
if (lastColonIndex === -1) return;
// Replace from : to cursor with :stickerName:
const before = message.slice(0, lastColonIndex);
const after = message.slice(cursorPos);
const stickerText = `:${sticker.name}:`;
message = before + stickerText + after;
showAutocomplete = false;
// Set cursor position after the inserted sticker
tick().then(() => {
const newPos = lastColonIndex + stickerText.length;
inputElement.focus();
inputElement.setSelectionRange(newPos, newPos);
});
}
$: activeTimerLabel = timerOptions.find(t => t.value === selfDestructSeconds)?.label || 'Off';
function handleSubmit(event) {
event.preventDefault();
if (!message.trim() || disabled) return;
const trimmedMessage = message.trim();
lastSentMessage = trimmedMessage;
dispatch('send', { message: trimmedMessage, selfDestructSeconds });
message = '';
selfDestructSeconds = 0; // Reset timer after sending
}
function handleKeyDown(event) {
// Handle autocomplete keyboard navigation
if (showAutocomplete && autocompleteSuggestions.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = (selectedIndex + 1) % autocompleteSuggestions.length;
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = (selectedIndex - 1 + autocompleteSuggestions.length) % autocompleteSuggestions.length;
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
selectAutocomplete(autocompleteSuggestions[selectedIndex]);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
showAutocomplete = false;
return;
}
}
// Recall last sent message with up arrow when input is empty
if (event.key === 'ArrowUp' && message === '' && lastSentMessage) {
event.preventDefault();
message = lastSentMessage;
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit(event);
}
}
function selectTimer(value) {
selfDestructSeconds = value;
showTimerMenu = false;
}
function handleClickOutside(event) {
if (showTimerMenu && !event.target.closest('.timer-container')) {
showTimerMenu = false;
}
if (showFavoritesMenu && !event.target.closest('.favorites-container')) {
showFavoritesMenu = false;
}
if (favoriteContextMenu && !event.target.closest('.favorite-context-menu')) {
favoriteContextMenu = null;
}
if (showAutocomplete && !event.target.closest('.autocomplete-dropdown') && !event.target.closest('.input-wrapper')) {
showAutocomplete = false;
}
}
function selectFavorite(stickerName) {
insertText(`:${stickerName}:`);
showFavoritesMenu = false;
}
function handleFavoriteContextMenu(event, stickerName) {
event.preventDefault();
favoriteContextMenu = {
stickerName,
x: event.clientX,
y: event.clientY
};
}
function removeFavorite() {
if (favoriteContextMenu && confirm(`Remove "${favoriteContextMenu.stickerName}" from favorites?`)) {
stickerFavorites.toggle(favoriteContextMenu.stickerName);
}
favoriteContextMenu = null;
}
function copyImageLink() {
if (favoriteContextMenu) {
const url = $sharedStickersMap[favoriteContextMenu.stickerName.toLowerCase()];
if (url) {
const fullUrl = window.location.origin + url;
navigator.clipboard.writeText(fullUrl);
}
}
favoriteContextMenu = null;
}
function openImageInNewTab() {
if (favoriteContextMenu) {
const url = $sharedStickersMap[favoriteContextMenu.stickerName.toLowerCase()];
if (url) {
window.open(url, '_blank');
}
}
favoriteContextMenu = null;
}
</script>
<svelte:window on:click={handleClickOutside} />
<form class="chat-input" on:submit={handleSubmit}>
<div class="input-wrapper">
<div class="input-icons">
<div class="timer-container">
<button
type="button"
class="timer-btn"
class:active={selfDestructSeconds > 0}
on:click={() => showTimerMenu = !showTimerMenu}
title="Self-destruct timer"
{disabled}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
</svg>
{#if selfDestructSeconds > 0}
<span class="timer-value">{activeTimerLabel}</span>
{/if}
</button>
{#if showTimerMenu}
<div class="timer-menu">
{#each timerOptions as option}
<button
type="button"
class="timer-option"
class:selected={selfDestructSeconds === option.value}
on:click={() => selectTimer(option.value)}
>
{option.label}
</button>
{/each}
</div>
{/if}
</div>
{#if $stickerFavorites.length > 0}
<div class="favorites-container">
<button
type="button"
class="favorites-btn"
on:click={() => showFavoritesMenu = !showFavoritesMenu}
title="Favorite stickers"
{disabled}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
</svg>
</button>
{#if showFavoritesMenu}
<div class="favorites-menu">
{#each $stickerFavorites as stickerName}
<button
type="button"
class="favorite-item"
on:click={() => selectFavorite(stickerName)}
on:contextmenu={(e) => handleFavoriteContextMenu(e, stickerName)}
title=":{stickerName}: (right-click for options)"
>
{#if $sharedStickersMap[stickerName.toLowerCase()]}
<img src={$sharedStickersMap[stickerName.toLowerCase()]} alt={stickerName} />
{:else}
<span class="sticker-name">:{stickerName}:</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<input
type="text"
bind:this={inputElement}
bind:value={message}
on:keydown={handleKeyDown}
on:input={handleInput}
placeholder={disabled
? 'Connecting to chat...'
: `Chat as ${username || 'Guest'}...`}
{disabled}
maxlength={maxLength}
/>
<span class="char-count" class:warning={message.length > maxLength * 0.9}>
{message.length}/{maxLength}
</span>
{#if showAutocomplete && autocompleteSuggestions.length > 0}
<div class="autocomplete-dropdown">
{#each autocompleteSuggestions as sticker, index}
<button
type="button"
class="autocomplete-item"
class:selected={index === selectedIndex}
on:click={() => selectAutocomplete(sticker)}
on:mouseenter={() => selectedIndex = index}
>
<img
src={sticker.filePath}
alt={sticker.name}
class="autocomplete-preview"
/>
<span class="autocomplete-name">:{sticker.name}:</span>
</button>
{/each}
</div>
{/if}
</div>
</form>
{#if favoriteContextMenu}
<div
class="favorite-context-menu"
style="left: {favoriteContextMenu.x}px; top: {favoriteContextMenu.y}px;"
>
<button on:click={openImageInNewTab}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z"/>
<path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z"/>
</svg>
Open in new tab
</button>
<button on:click={copyImageLink}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
Copy image link
</button>
<div class="context-menu-divider"></div>
<button class="danger" on:click={removeFavorite}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.5 1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1H3v9a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4h.5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1H2.5zm3 4a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5zM8 5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7A.5.5 0 0 1 8 5zm3 .5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 1 0z"/>
</svg>
Remove from favorites
</button>
</div>
{/if}
<style>
.chat-input {
display: flex;
gap: 0.5rem;
padding: 0;
border-top: 1px solid #333;
background: #0d0d0d;
flex-shrink: 0; /* Prevent input from shrinking */
}
.input-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
background: #222;
border: 1px solid #333;
border-radius: 4px;
overflow: visible;
}
.input-wrapper:focus-within {
border-color: #4a9eff;
}
.input-icons {
display: flex;
align-items: center;
gap: 0.25rem;
padding-left: 0.5rem;
flex-shrink: 0;
}
input {
flex: 1;
padding: 0.75rem 0.5rem;
padding-right: 4rem;
background: transparent;
border: none;
color: #fff;
font-size: 0.875rem;
}
input:focus {
outline: none;
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.char-count {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.75rem;
color: #666;
pointer-events: none;
}
.char-count.warning {
color: #ff9800;
}
/* Sticker autocomplete dropdown */
.autocomplete-dropdown {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 0.5rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
max-height: 300px;
overflow-y: auto;
}
.autocomplete-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
color: #ccc;
font-size: 0.85rem;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.autocomplete-item:hover,
.autocomplete-item.selected {
background: rgba(74, 158, 255, 0.15);
color: #fff;
}
.autocomplete-preview {
width: 28px;
height: 28px;
object-fit: contain;
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
}
.autocomplete-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timer-container {
position: relative;
}
.timer-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.35rem;
background: transparent;
border: none;
border-radius: 4px;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.timer-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #999;
}
.timer-btn.active {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
}
.timer-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.timer-value {
font-size: 0.7rem;
font-weight: 600;
}
.timer-menu {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 0.5rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.timer-option {
display: block;
width: 100%;
padding: 0.5rem 1rem;
background: none;
border: none;
color: #ccc;
font-size: 0.8rem;
cursor: pointer;
text-align: left;
transition: all 0.15s;
}
.timer-option:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.timer-option.selected {
background: rgba(255, 152, 0, 0.2);
color: #ff9800;
}
.favorites-container {
position: relative;
}
.favorites-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
background: transparent;
border: none;
border-radius: 4px;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.favorites-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #f5c518;
}
.favorites-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.favorites-menu {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 0.5rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.5rem;
max-width: 200px;
}
.favorite-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.favorite-item:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #444;
}
.favorite-item img {
max-width: 32px;
max-height: 32px;
object-fit: contain;
}
.favorite-item .sticker-name {
font-size: 0.7rem;
color: #999;
}
.favorite-context-menu {
position: fixed;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
overflow: hidden;
min-width: 180px;
}
.favorite-context-menu button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 0.75rem;
background: none;
border: none;
color: #ccc;
font-size: 0.85rem;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.favorite-context-menu button:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.favorite-context-menu button.danger {
color: #ff6b6b;
}
.favorite-context-menu button.danger:hover {
background: rgba(255, 107, 107, 0.15);
color: #ff6b6b;
}
.context-menu-divider {
height: 1px;
background: #333;
margin: 0.25rem 0;
}
</style>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,733 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { fly, fade, slide } from 'svelte/transition';
import { browser } from '$app/environment';
import { isAuthenticated } from '$lib/stores/auth';
import { connectionStatus } from '$lib/chat/chatStore';
import TerminalTabBar from '$lib/components/terminal/TerminalTabBar.svelte';
import TerminalCore from '$lib/components/terminal/TerminalCore.svelte';
import StreamsBrowser from '$lib/components/terminal/StreamsBrowser.svelte';
import WatchRoomsBrowser from '$lib/components/terminal/WatchRoomsBrowser.svelte';
import AudioBrowser from '$lib/components/terminal/AudioBrowser.svelte';
import EbookBrowser from '$lib/components/terminal/EbookBrowser.svelte';
import TreasuryBrowser from '$lib/components/terminal/TreasuryBrowser.svelte';
import GamesBrowser from '$lib/components/terminal/GamesBrowser.svelte';
import StickerBrowser from './StickerBrowser.svelte';
import ProfilePreview from './ProfilePreview.svelte';
export let isOpen = false;
export let defaultRealmId = null;
// Date/time state
let currentTime = new Date();
let showCalendar = false;
let calendarDate = new Date();
let timeInterval;
// Tab navigation - includes audio, ebooks, games, and treasury
let activeTab = 'terminal';
const tabs = [
{ id: 'terminal', label: 'Terminal' },
{ id: 'stickers', label: 'Stickers' },
{ id: 'streams', label: 'Streams' },
{ id: 'watch', label: 'Watch', color: '#10b981' },
{ id: 'audio', label: 'Audio', color: '#ec4899' },
{ id: 'ebooks', label: 'eBooks', color: '#3b82f6' },
{ id: 'games', label: 'Games', color: '#f59e0b' },
{ id: 'treasury', label: 'Treasury', color: '#ffd700' }
];
// State
let selectedRealmId = defaultRealmId;
let renderStickers = false;
let isDocked = true;
let terminalHeight = 333;
let isResizing = false;
let terminalPosition = { x: 100, y: 100 };
let isDragging = false;
let terminalHotkey = '`';
let activeProfilePreview = null;
let terminalCore;
$: isConnected = $connectionStatus === 'connected';
// Global hotkey handler - only for authenticated users
function handleKeyDown(event) {
// Terminal is only available for authenticated users
if (!$isAuthenticated) return;
if (event.key === terminalHotkey && !event.ctrlKey && !event.altKey && !event.metaKey) {
const target = event.target;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
if (!target.classList.contains('terminal-input')) {
return;
}
}
event.preventDefault();
isOpen = !isOpen;
if (isOpen && terminalCore) {
terminalCore.focusInput();
}
}
if (event.key === 'Escape' && isOpen) {
isOpen = false;
}
}
// Resize handling (docked mode)
function startResize(e) {
if (!isDocked) return;
isResizing = true;
e.preventDefault();
}
function handleMouseMove(e) {
if (isResizing) {
const navHeight = 60;
const newHeight = e.clientY - navHeight;
terminalHeight = Math.max(200, Math.min(window.innerHeight - navHeight - 100, newHeight));
} else if (isDragging && !isDocked) {
terminalPosition.x += e.movementX;
terminalPosition.y += e.movementY;
}
}
function stopResize() {
isResizing = false;
isDragging = false;
}
// Drag handling (undocked mode)
function startDrag(e) {
if (!isDocked && !e.target.closest('button')) {
isDragging = true;
e.preventDefault();
}
}
function toggleDock() {
isDocked = !isDocked;
if (isDocked) {
terminalHeight = 500;
}
}
function popoutTerminal() {
const realmParam = selectedRealmId ? `?realm=${selectedRealmId}` : '';
const popoutUrl = `/chat/terminal${realmParam}`;
const popoutWindow = window.open(
popoutUrl,
'TerminalPopout',
'width=600,height=500,menubar=no,toolbar=no,location=no,status=no'
);
if (popoutWindow) {
isOpen = false;
}
}
// Tab change handler
function handleTabChange(event) {
activeTab = event.detail.tab;
}
// Sticker selection
function handleStickerSelect(stickerText) {
if (terminalCore) {
// Insert sticker text into terminal - need to access input through exposed method
// For now, switch to terminal tab and the user can paste
}
activeTab = 'terminal';
if (terminalCore) {
terminalCore.focusInput();
}
}
// Profile preview handlers
function handleShowProfile(event) {
const { username, userId, isGuest, messageId, position } = event.detail;
activeProfilePreview = { username, userId, isGuest, messageId, position };
}
function handleProfileClose() {
activeProfilePreview = null;
}
function handleRealmChange(event) {
selectedRealmId = event.detail.realmId;
}
function handleStickersToggled(event) {
renderStickers = event.detail.renderStickers;
}
// Date/time formatting
function formatTime(date) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
function formatDate(date) {
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
function toggleCalendar() {
showCalendar = !showCalendar;
if (showCalendar) {
calendarDate = new Date();
}
}
// Calendar helpers
function getCalendarDays(date) {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startDayOfWeek = firstDay.getDay();
const days = [];
// Add empty slots for days before the first of the month
for (let i = 0; i < startDayOfWeek; i++) {
days.push(null);
}
// Add the days of the month
for (let i = 1; i <= daysInMonth; i++) {
days.push(i);
}
return days;
}
function prevMonth() {
calendarDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1);
}
function nextMonth() {
calendarDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1);
}
function isToday(day) {
if (!day) return false;
const today = new Date();
return day === today.getDate() &&
calendarDate.getMonth() === today.getMonth() &&
calendarDate.getFullYear() === today.getFullYear();
}
$: calendarDays = getCalendarDays(calendarDate);
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
// Timezone definitions
const timezones = [
{ label: 'UTC', zone: 'UTC' },
{ label: 'Germany', zone: 'Europe/Berlin' },
{ label: 'India', zone: 'Asia/Kolkata' },
{ label: 'Japan', zone: 'Asia/Tokyo' },
{ label: 'Australia', zone: 'Australia/Sydney' },
{ label: 'PST', zone: 'America/Los_Angeles' },
{ label: 'MST', zone: 'America/Denver' },
{ label: 'Central', zone: 'America/Chicago' },
{ label: 'EST', zone: 'America/New_York' }
];
function getTimezoneTime(zone) {
return currentTime.toLocaleTimeString('en-US', {
timeZone: zone,
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
onMount(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', stopResize);
const savedHotkey = localStorage.getItem('terminalHotkey');
if (savedHotkey) {
terminalHotkey = savedHotkey;
}
// Update time every second
timeInterval = setInterval(() => {
currentTime = new Date();
}, 1000);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', stopResize);
if (timeInterval) clearInterval(timeInterval);
};
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', stopResize);
if (timeInterval) clearInterval(timeInterval);
});
</script>
<svelte:window on:click={() => showCalendar = false} />
{#if isOpen && $isAuthenticated}
<div
class="terminal-container"
class:docked={isDocked}
class:undocked={!isDocked}
transition:slide={{ duration: isDocked ? 300 : 0 }}
style={isDocked ? `height: ${terminalHeight}px;` : `left: ${terminalPosition.x}px; top: ${terminalPosition.y}px;`}
>
{#if isDocked}
<div class="resize-handle" on:mousedown={startResize}></div>
{/if}
<div class="terminal-header" on:mousedown={!isDocked ? startDrag : null}>
<TerminalTabBar {tabs} {activeTab} on:tabChange={handleTabChange} />
<div class="header-right">
<div class="datetime-container">
<button class="datetime-button" on:click|stopPropagation={toggleCalendar} title="Show calendar">
<span class="datetime-date">{formatDate(currentTime)}</span>
<span class="datetime-time">{formatTime(currentTime)}</span>
</button>
{#if showCalendar}
<div class="calendar-dropdown" on:click|stopPropagation>
<div class="calendar-panel">
<div class="calendar-header">
<button class="calendar-nav" on:click={prevMonth}></button>
<span class="calendar-month">{calendarMonthYear}</span>
<button class="calendar-nav" on:click={nextMonth}></button>
</div>
<div class="calendar-weekdays">
<span>Su</span><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span>
</div>
<div class="calendar-days">
{#each calendarDays as day}
<span class="calendar-day" class:today={isToday(day)} class:empty={!day}>
{day || ''}
</span>
{/each}
</div>
</div>
<div class="timezone-panel">
{#each timezones as tz}
<div class="timezone-row">
<span class="timezone-label">{tz.label}</span>
<span class="timezone-time">{getTimezoneTime(tz.zone)}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
<div class="status">
<span class="status-dot" class:connected={isConnected}></span>
</div>
<div class="terminal-controls">
<button class="control-button" on:click={popoutTerminal} title="Pop out terminal">
<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>
<button class="control-button" on:click={toggleDock} title={isDocked ? 'Undock' : 'Dock'}>
{#if isDocked}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 2H2v12h12V2zM3 13V3h10v10H3z"/>
</svg>
{:else}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 10h12v4H2v-4zm0-8h12v6H2V2z"/>
</svg>
{/if}
</button>
<button class="close-button" on:click={() => (isOpen = false)}>×</button>
</div>
</div>
</div>
<div class="tab-content">
{#if activeTab === 'terminal'}
<TerminalCore
bind:this={terminalCore}
realmId={selectedRealmId}
{renderStickers}
showHotkeyHelp={true}
{terminalHotkey}
isActive={activeTab === 'terminal'}
on:showProfile={handleShowProfile}
on:realmChange={handleRealmChange}
on:stickersToggled={handleStickersToggled}
/>
{:else if activeTab === 'stickers'}
<StickerBrowser onSelect={handleStickerSelect} />
{:else if activeTab === 'streams'}
<StreamsBrowser isActive={activeTab === 'streams'} />
{:else if activeTab === 'watch'}
<WatchRoomsBrowser isActive={activeTab === 'watch'} />
{:else if activeTab === 'audio'}
<AudioBrowser isActive={activeTab === 'audio'} />
{:else if activeTab === 'ebooks'}
<EbookBrowser isActive={activeTab === 'ebooks'} />
{:else if activeTab === 'games'}
<GamesBrowser isActive={activeTab === 'games'} />
{:else if activeTab === 'treasury'}
<TreasuryBrowser isActive={activeTab === 'treasury'} />
{/if}
</div>
{#if activeProfilePreview}
<ProfilePreview
username={activeProfilePreview.username}
userId={activeProfilePreview.userId}
isGuest={activeProfilePreview.isGuest}
messageId={activeProfilePreview.messageId}
position={activeProfilePreview.position}
realmId={selectedRealmId}
on:close={handleProfileClose}
/>
{/if}
</div>
{/if}
<style>
.terminal-container {
position: fixed;
background: transparent;
display: flex;
flex-direction: column;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
z-index: 9999;
}
.terminal-container.docked {
top: var(--nav-height, 60px);
left: 0;
right: 0;
border-bottom: 1px solid #333;
}
.terminal-container.undocked {
width: 600px;
height: 400px;
border: 1px solid #333;
border-radius: 8px;
resize: both;
overflow: hidden;
}
.resize-handle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: #333;
cursor: ns-resize;
z-index: 10;
}
.resize-handle:hover,
.resize-handle:active {
background: #4caf50;
}
.terminal-header {
display: flex;
align-items: center;
padding: 0.5rem;
background: #0d0d0d;
border-bottom: 1px solid #333;
gap: 0.375rem;
user-select: none;
flex-shrink: 0;
}
.docked .terminal-header {
cursor: default;
}
.undocked .terminal-header {
cursor: move;
}
.header-right {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.status {
display: flex;
align-items: center;
justify-content: center;
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;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #f44336;
}
.status-dot.connected {
background: #4caf50;
}
.terminal-controls {
display: flex;
gap: 0.375rem;
align-items: center;
}
.control-button {
display: flex;
align-items: center;
justify-content: center;
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;
transition: all 0.15s;
}
.control-button:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
color: #fff;
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
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;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.15s;
}
.close-button:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
color: #fff;
}
.tab-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: rgb(13, 17, 23);
opacity: 0.9;
}
.datetime-container {
position: relative;
}
.datetime-button {
display: flex;
align-items: center;
gap: 0.5rem;
height: 26px;
padding: 0 0.5rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #aaa;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.datetime-button:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
color: #fff;
}
.datetime-date {
color: #fff;
}
.datetime-time {
color: #4caf50;
}
.calendar-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 0.75rem;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
display: flex;
gap: 0.75rem;
}
.calendar-panel {
min-width: 200px;
}
.timezone-panel {
border-left: 1px solid #30363d;
padding-left: 0.75rem;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 100px;
}
.timezone-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.15rem 0;
}
.timezone-label {
color: #8b949e;
font-size: 0.7rem;
}
.timezone-time {
color: #7ee787;
font-size: 0.75rem;
font-weight: 600;
font-family: 'Consolas', 'Monaco', monospace;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.calendar-month {
color: #c9d1d9;
font-size: 0.9rem;
font-weight: 600;
}
.calendar-nav {
background: none;
border: none;
color: #8b949e;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.15s;
}
.calendar-nav:hover {
background: rgba(255, 255, 255, 0.1);
color: #c9d1d9;
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 0.25rem;
}
.calendar-weekdays span {
text-align: center;
color: #8b949e;
font-size: 0.7rem;
padding: 0.25rem;
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.calendar-day {
text-align: center;
padding: 0.35rem;
font-size: 0.8rem;
color: #c9d1d9;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.calendar-day:not(.empty):not(.today):hover {
background: rgba(139, 148, 158, 0.2);
}
.calendar-day.empty {
color: transparent;
cursor: default;
}
.calendar-day.today {
background: #7ee787;
color: #0d1117;
font-weight: 600;
}
.calendar-day.today:hover {
background: #9eeea1;
}
.timezone-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.15rem 0.35rem;
margin: 0 -0.35rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.timezone-row:hover {
background: rgba(126, 231, 135, 0.15);
}
.timezone-row:hover .timezone-label {
color: #c9d1d9;
}
</style>

View file

@ -0,0 +1,685 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { hiddenUsers, toggleHideUser, chatUserInfo } from '$lib/chat/chatStore';
import { chatWebSocket as chatWs } from '$lib/chat/chatWebSocket';
import { formatUbercoin } from '$lib/stores/ubercoin';
import { auth, isAuthenticated } from '$lib/stores/auth';
import UbercoinTipModal from '$lib/components/UbercoinTipModal.svelte';
export let username = '';
export let userId = null;
export let isGuest = false;
export let position = { x: 0, y: 0 };
export let targetFingerprint = ''; // For guest bans
const dispatch = createEventDispatcher();
// Check current user's moderation permissions from chat connection
$: currentUser = $chatUserInfo;
$: canUberban = currentUser?.isAdmin || currentUser?.isSiteModerator;
$: canModerate = currentUser?.isModerator || currentUser?.isAdmin || currentUser?.isSiteModerator;
$: isSelf = currentUser?.userId === userId;
let profile = null;
let loading = true;
let error = null;
let popupElement;
let showTipModal = false;
// Check if this user is hidden
$: isHidden = userId && $hiddenUsers.has(userId);
onMount(async () => {
// Add click outside listener
document.addEventListener('click', handleClickOutside);
// Fetch profile for registered users
if (!isGuest && username) {
await fetchProfile();
} else {
loading = false;
}
// Adjust position to stay within viewport
requestAnimationFrame(adjustPosition);
});
onDestroy(() => {
document.removeEventListener('click', handleClickOutside);
});
function handleClickOutside(event) {
if (popupElement && !popupElement.contains(event.target)) {
dispatch('close');
}
}
async function fetchProfile() {
try {
const response = await fetch(`/api/users/${encodeURIComponent(username)}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.success) {
profile = data.profile;
}
}
} catch (e) {
console.error('Failed to fetch profile:', e);
error = 'Failed to load profile';
}
loading = false;
}
function adjustPosition() {
if (!popupElement) return;
const rect = popupElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Adjust if goes off right edge
if (rect.right > viewportWidth - 10) {
position.x = viewportWidth - rect.width - 10;
}
// Adjust if goes off bottom edge
if (rect.bottom > viewportHeight - 10) {
position.y = position.y - rect.height - 20;
}
// Ensure not off left edge
if (position.x < 10) {
position.x = 10;
}
// Ensure not off top edge
if (position.y < 10) {
position.y = 10;
}
}
function handleViewProfile() {
// Navigate to profile page
window.location.href = `/profile/${encodeURIComponent(username)}`;
}
function handleToggleHide() {
if (userId) {
toggleHideUser(userId);
}
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short'
});
}
function handleOpenTipModal() {
showTipModal = true;
}
function handleTipModalClose() {
showTipModal = false;
}
function handleTipSent(event) {
// Tip was sent successfully
showTipModal = false;
}
// Check if user can send tips (authenticated and not viewing self)
$: canSendTip = $isAuthenticated && $auth.user && $auth.user.username !== username;
// Moderation action handlers
function handleUberban() {
if (!confirm(`Permanently ban ${username} from the entire site? This bans their browser fingerprint.`)) {
return;
}
chatWs.uberbanUser(userId, targetFingerprint, '');
dispatch('close');
}
function handleBan() {
if (!confirm(`Ban ${username} from this realm?`)) {
return;
}
chatWs.banUser(userId, '');
dispatch('close');
}
function handleKick() {
chatWs.kickUser(userId, 60, '');
dispatch('close');
}
function handleMute() {
chatWs.muteUser(userId, 0, ''); // 0 = permanent
dispatch('close');
}
</script>
<style>
.profile-preview {
position: fixed;
z-index: 1000;
background: #1a1a1a;
border: 1px solid var(--border, #333);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 280px;
overflow: hidden;
animation: fadeIn 0.15s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.banner-container {
width: 100%;
height: 60px;
overflow: hidden;
position: relative;
}
.banner {
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-placeholder {
width: 100%;
height: 60px;
}
.profile-content {
padding: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #333;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: white;
overflow: hidden;
flex-shrink: 0;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-details {
flex: 1;
min-width: 0;
}
.username-row {
display: flex;
align-items: center;
gap: 8px;
}
.username {
font-weight: 600;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: filter 0.15s ease;
}
.username:hover {
filter: invert(1);
}
.color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.guest-badge {
font-size: 0.7rem;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.member-since {
font-size: 0.8rem;
color: #888;
margin-top: 2px;
}
.bio {
font-size: 0.85rem;
color: #ccc;
line-height: 1.4;
margin-bottom: 12px;
max-height: 60px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.graffiti-container {
margin-bottom: 12px;
padding: 8px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
display: flex;
justify-content: center;
}
.graffiti-img {
image-rendering: pixelated;
width: 88px;
height: 33px;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.action-btn {
padding: 8px 12px;
background: transparent;
border: 1px solid var(--border, #333);
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s ease;
text-align: left;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.loading, .error-state {
padding: 20px;
text-align: center;
color: #888;
}
.guest-card {
padding: 16px;
}
.guest-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.guest-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #333;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #666;
}
.hide-btn {
display: flex;
align-items: center;
gap: 6px;
color: #999;
}
.hide-btn:hover {
color: #f44336;
background: rgba(244, 67, 54, 0.1);
}
.hide-btn.unhide {
color: #4caf50;
}
.hide-btn.unhide:hover {
color: #66bb6a;
background: rgba(76, 175, 80, 0.1);
}
.hide-btn svg {
flex-shrink: 0;
}
.ubercoin-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: rgba(255, 215, 0, 0.08);
border-radius: 6px;
margin-bottom: 12px;
}
.ubercoin-balance {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: #ffd700;
}
.coin-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border-radius: 50%;
font-size: 0.7rem;
font-weight: bold;
color: #000;
}
.tip-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border: none;
border-radius: 4px;
color: #000;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.tip-btn:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.tip-btn .coin-icon {
width: 16px;
height: 16px;
font-size: 0.6rem;
}
/* Moderation Actions */
.mod-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border, #333);
}
.mod-btn {
flex: 1;
min-width: 60px;
padding: 6px 10px;
font-size: 0.75rem;
text-align: center;
}
.mod-btn.kick {
color: #ffc107;
border-color: #ffc107;
}
.mod-btn.kick:hover {
background: rgba(255, 193, 7, 0.15);
}
.mod-btn.mute {
color: #9c27b0;
border-color: #9c27b0;
}
.mod-btn.mute:hover {
background: rgba(156, 39, 176, 0.15);
}
.mod-btn.ban {
color: #f44336;
border-color: #f44336;
}
.mod-btn.ban:hover {
background: rgba(244, 67, 54, 0.15);
}
.mod-btn.uberban {
color: #b71c1c;
border-color: #b71c1c;
font-weight: bold;
}
.mod-btn.uberban:hover {
background: rgba(183, 28, 28, 0.2);
}
</style>
<div
class="profile-preview"
bind:this={popupElement}
style="left: {position.x}px; top: {position.y}px;"
on:click|stopPropagation
>
{#if isGuest}
<!-- Guest User Card -->
<div class="guest-card">
<div class="guest-info">
<div class="guest-avatar">G</div>
<div class="user-details">
<div class="username-row">
<span class="username">{username}</span>
<span class="guest-badge">Guest</span>
</div>
</div>
</div>
<div class="actions">
<button class="action-btn hide-btn" class:unhide={isHidden} on:click={handleToggleHide}>
{#if isHidden}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
<path d="M2 8s3-5.5 6-5.5S14 8 14 8s-3 5.5-6 5.5S2 8 2 8zm-1 0a.5.5 0 0 0 0 1c0-.552.93-1.752 2.06-2.715.45-.383.937-.727 1.44-1.017C5.478 4.676 6.68 4 8 4c1.32 0 2.522.676 3.5 1.268.503.29.99.634 1.44 1.017C14.07 7.248 15 8.448 15 9a.5.5 0 0 0 0-1c0 .552-.93 1.752-2.06 2.715-.45.383-.937.727-1.44 1.017C10.522 12.324 9.32 13 8 13c-1.32 0-2.522-.676-3.5-1.268-.503-.29-.99-.634-1.44-1.017C1.93 9.752 1 8.552 1 8z"/>
</svg>
Show Messages
{:else}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
</svg>
Hide Messages
{/if}
</button>
<!-- Moderation Actions for Guests -->
{#if canModerate}
<div class="mod-actions">
<button class="action-btn mod-btn kick" on:click={handleKick} title="Kick (1 min block)">
Kick
</button>
<button class="action-btn mod-btn mute" on:click={handleMute} title="Mute">
Mute
</button>
<button class="action-btn mod-btn ban" on:click={handleBan} title="Ban from realm">
Ban
</button>
{#if canUberban}
<button class="action-btn mod-btn uberban" on:click={handleUberban} title="Site-wide fingerprint ban">
Uberban
</button>
{/if}
</div>
{/if}
</div>
</div>
{:else if loading}
<div class="loading">Loading profile...</div>
{:else if error}
<div class="error-state">{error}</div>
{:else if profile}
<!-- Registered User Card -->
{#if profile.bannerUrl}
<div class="banner-container">
<img
src={profile.bannerUrl}
alt="Banner"
class="banner"
style="object-position: {profile.bannerPositionX ?? 50}% {profile.bannerPosition ?? 50}%; transform: scale({(profile.bannerZoom ?? 100) / 100}); transform-origin: {profile.bannerPositionX ?? 50}% {profile.bannerPosition ?? 50}%;"
/>
</div>
{:else}
<div
class="banner-placeholder"
style="background: linear-gradient(135deg, {profile.colorCode || '#561D5E'} 0%, {profile.colorCode || '#561D5E'}66 100%);"
></div>
{/if}
<div class="profile-content">
<div class="user-info">
<div class="avatar">
{#if profile.avatarUrl}
<img src={profile.avatarUrl} alt={profile.username} />
{:else}
{profile.username.charAt(0).toUpperCase()}
{/if}
</div>
<div class="user-details">
<div class="username-row">
<span class="username" style="color: {profile.colorCode || '#561D5E'};">{profile.username}</span>
<div
class="color-dot"
style="background: {profile.colorCode || '#561D5E'};"
></div>
</div>
<div class="member-since">
Member since {formatDate(profile.createdAt)}
</div>
</div>
</div>
{#if profile.bio}
<div class="bio">{profile.bio}</div>
{/if}
{#if profile.graffitiUrl}
<div class="graffiti-container">
<img
src={profile.graffitiUrl}
alt="{profile.username}'s graffiti"
class="graffiti-img"
/>
</div>
{/if}
<!-- übercoin Section -->
<div class="ubercoin-section">
<div class="ubercoin-balance">
<span class="coin-icon">Ü</span>
<span>{formatUbercoin(profile.ubercoinBalance)}</span>
</div>
{#if canSendTip}
<button class="tip-btn" on:click|stopPropagation={handleOpenTipModal}>
<span class="coin-icon">Ü</span>
Send
</button>
{/if}
</div>
<div class="actions">
<button class="action-btn" on:click={handleViewProfile}>
View Full Profile
</button>
<button class="action-btn hide-btn" class:unhide={isHidden} on:click={handleToggleHide}>
{#if isHidden}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
<path d="M2 8s3-5.5 6-5.5S14 8 14 8s-3 5.5-6 5.5S2 8 2 8zm-1 0a.5.5 0 0 0 0 1c0-.552.93-1.752 2.06-2.715.45-.383.937-.727 1.44-1.017C5.478 4.676 6.68 4 8 4c1.32 0 2.522.676 3.5 1.268.503.29.99.634 1.44 1.017C14.07 7.248 15 8.448 15 9a.5.5 0 0 0 0-1c0 .552-.93 1.752-2.06 2.715-.45.383-.937.727-1.44 1.017C10.522 12.324 9.32 13 8 13c-1.32 0-2.522-.676-3.5-1.268-.503-.29-.99-.634-1.44-1.017C1.93 9.752 1 8.552 1 8z"/>
</svg>
Show Messages
{:else}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
</svg>
Hide Messages
{/if}
</button>
<!-- Moderation Actions for Registered Users -->
{#if canModerate && !isSelf}
<div class="mod-actions">
<button class="action-btn mod-btn kick" on:click={handleKick} title="Kick (1 min block)">
Kick
</button>
<button class="action-btn mod-btn mute" on:click={handleMute} title="Mute">
Mute
</button>
<button class="action-btn mod-btn ban" on:click={handleBan} title="Ban from realm">
Ban
</button>
</div>
{/if}
</div>
</div>
{:else}
<div class="error-state">Profile not found</div>
{/if}
</div>
<!-- Ubercoin Tip Modal -->
<UbercoinTipModal
show={showTipModal}
recipientUsername={username}
on:close={handleTipModalClose}
on:sent={handleTipSent}
/>

View file

@ -0,0 +1,515 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { stickerFavorites } from '$lib/chat/stickerFavorites';
import { stickers as sharedStickers, ensureLoaded, isLoaded } from '$lib/stores/stickers';
export let onSelect = null; // callback when sticker is selected for insertion
let filteredStickers = [];
let searchQuery = '';
let loading = true;
let error = null;
// Lazy loading state
let visibleStickers = [];
let batchSize = 50;
let loadedCount = 0;
let containerElement;
let sentinelElement;
let observer;
// Context menu state
let contextMenu = null; // { sticker, x, y }
onMount(async () => {
// Use shared sticker store - only fetches once across all components
loading = true;
try {
await ensureLoaded();
applyFilter();
} catch (e) {
error = 'Failed to load stickers';
} finally {
loading = false;
}
setupIntersectionObserver();
});
onDestroy(() => {
if (observer) {
observer.disconnect();
}
});
function applyFilter() {
const query = searchQuery.toLowerCase().trim();
if (query) {
filteredStickers = $sharedStickers.filter(s =>
s.name.toLowerCase().includes(query)
);
} else {
filteredStickers = [...$sharedStickers];
}
// Reset lazy loading
loadedCount = 0;
loadMoreStickers();
}
function loadMoreStickers() {
const nextBatch = filteredStickers.slice(loadedCount, loadedCount + batchSize);
if (nextBatch.length > 0) {
visibleStickers = [...visibleStickers.slice(0, loadedCount), ...nextBatch];
loadedCount += nextBatch.length;
}
}
function setupIntersectionObserver() {
if (!sentinelElement) return;
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && loadedCount < filteredStickers.length) {
loadMoreStickers();
}
});
}, {
root: containerElement,
rootMargin: '100px',
threshold: 0
});
observer.observe(sentinelElement);
}
function handleSearch() {
visibleStickers = [];
applyFilter();
}
function handleStickerClick(sticker) {
if (onSelect) {
onSelect(`:${sticker.name}:`);
}
}
function handleContextMenu(event, sticker) {
event.preventDefault();
contextMenu = {
sticker,
x: event.clientX,
y: event.clientY
};
}
function closeContextMenu() {
contextMenu = null;
}
function handleClickOutside(event) {
if (contextMenu && !event.target.closest('.sticker-context-menu')) {
closeContextMenu();
}
}
function toggleFavorite() {
if (contextMenu) {
stickerFavorites.toggle(contextMenu.sticker.name);
closeContextMenu();
}
}
function openInNewTab() {
if (contextMenu) {
window.open(contextMenu.sticker.filePath, '_blank');
closeContextMenu();
}
}
function copyImageUrl() {
if (contextMenu) {
const fullUrl = window.location.origin + contextMenu.sticker.filePath;
navigator.clipboard.writeText(fullUrl).catch(() => {});
closeContextMenu();
}
}
$: isFavorite = contextMenu ? $stickerFavorites.includes(contextMenu.sticker.name) : false;
// Get favorite stickers from the loaded stickers
$: favoriteStickers = $sharedStickers.filter(s => $stickerFavorites.includes(s.name));
// Re-apply filter when search changes
$: if (searchQuery !== undefined) {
handleSearch();
}
function handleFavoriteClick(sticker) {
if (onSelect) {
onSelect(`:${sticker.name}:`);
}
}
function handleFavoriteContextMenu(event, sticker) {
event.preventDefault();
contextMenu = {
sticker,
x: event.clientX,
y: event.clientY
};
}
</script>
<svelte:window on:click={handleClickOutside} />
<div class="sticker-browser">
<!-- Favorites Section -->
{#if favoriteStickers.length > 0}
<div class="favorites-section">
<div class="favorites-header">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1.314C12.438-3.248 23.534 4.735 8 15-7.534 4.736 3.562-3.248 8 1.314z"/>
</svg>
<span>Favorites</span>
<span class="favorites-count">{favoriteStickers.length}</span>
</div>
<div class="favorites-grid">
{#each favoriteStickers as sticker (sticker.id)}
<button
class="favorite-sticker"
on:click={() => handleFavoriteClick(sticker)}
on:contextmenu={(e) => handleFavoriteContextMenu(e, sticker)}
title={`:${sticker.name}:`}
>
<img
src={sticker.filePath}
alt={sticker.name}
loading="lazy"
/>
</button>
{/each}
</div>
</div>
{/if}
<div class="search-bar">
<input
type="text"
placeholder="Search stickers..."
bind:value={searchQuery}
class="search-input"
/>
<span class="sticker-count">{filteredStickers.length} stickers</span>
</div>
{#if loading}
<div class="loading">Loading stickers...</div>
{:else if error}
<div class="error">{error}</div>
{:else if filteredStickers.length === 0}
<div class="empty">
{#if searchQuery}
No stickers found for "{searchQuery}"
{:else}
No stickers available
{/if}
</div>
{:else}
<div class="sticker-grid" bind:this={containerElement}>
{#each visibleStickers as sticker (sticker.id)}
<button
class="sticker-item"
class:favorite={$stickerFavorites.includes(sticker.name)}
on:click={() => handleStickerClick(sticker)}
on:contextmenu={(e) => handleContextMenu(e, sticker)}
title={`:${sticker.name}:`}
>
<img
src={sticker.filePath}
alt={sticker.name}
loading="lazy"
decoding="async"
/>
<span class="sticker-name">{sticker.name}</span>
</button>
{/each}
<!-- Sentinel for infinite scroll -->
<div bind:this={sentinelElement} class="sentinel"></div>
{#if loadedCount < filteredStickers.length}
<div class="loading-more">Loading more...</div>
{/if}
</div>
{/if}
</div>
<!-- Context Menu -->
{#if contextMenu}
<div
class="sticker-context-menu"
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
>
<button class="context-menu-item" on:click={toggleFavorite}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1.314C12.438-3.248 23.534 4.735 8 15-7.534 4.736 3.562-3.248 8 1.314z"/>
</svg>
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
</button>
<button class="context-menu-item" on:click={openInNewTab}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z"/>
<path d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z"/>
</svg>
Open in New Tab
</button>
<button class="context-menu-item" on:click={copyImageUrl}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
Copy Image URL
</button>
</div>
{/if}
<style>
.sticker-browser {
display: flex;
flex-direction: column;
height: 100%;
background: #0d1117;
}
/* Favorites Section */
.favorites-section {
background: #161b22;
border-bottom: 1px solid #30363d;
padding: 0.5rem 0.75rem;
}
.favorites-header {
display: flex;
align-items: center;
gap: 0.5rem;
color: #f85149;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.favorites-header svg {
opacity: 0.8;
}
.favorites-count {
color: #8b949e;
font-weight: 400;
}
.favorites-grid {
display: flex;
gap: 0.375rem;
overflow-x: auto;
padding-bottom: 0.25rem;
}
.favorites-grid::-webkit-scrollbar {
height: 4px;
}
.favorites-grid::-webkit-scrollbar-track {
background: transparent;
}
.favorites-grid::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 2px;
}
.favorite-sticker {
flex-shrink: 0;
width: 40px;
height: 40px;
padding: 0.25rem;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.favorite-sticker:hover {
background: #21262d;
border-color: #f85149;
transform: scale(1.05);
}
.favorite-sticker img {
width: 100%;
height: 100%;
object-fit: contain;
}
.search-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #161b22;
border-bottom: 1px solid #30363d;
}
.search-input {
flex: 1;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: #c9d1d9;
font-size: 0.875rem;
outline: none;
}
.search-input:focus {
border-color: #58a6ff;
}
.search-input::placeholder {
color: #6e7681;
}
.sticker-count {
color: #8b949e;
font-size: 0.75rem;
white-space: nowrap;
}
.sticker-grid {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
align-content: start;
}
.sticker-grid::-webkit-scrollbar {
width: 8px;
}
.sticker-grid::-webkit-scrollbar-track {
background: #0d1117;
}
.sticker-grid::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
.sticker-grid::-webkit-scrollbar-thumb:hover {
background: #484f58;
}
.sticker-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.sticker-item:hover {
background: #21262d;
border-color: #484f58;
transform: scale(1.02);
}
.sticker-item.favorite {
border-color: #f85149;
background: rgba(248, 81, 73, 0.1);
}
.sticker-item img {
width: 48px;
height: 48px;
object-fit: contain;
}
.sticker-name {
font-size: 0.65rem;
color: #8b949e;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.sentinel {
height: 1px;
width: 100%;
grid-column: 1 / -1;
}
.loading,
.error,
.empty,
.loading-more {
padding: 2rem;
text-align: center;
color: #8b949e;
}
.error {
color: #f85149;
}
.loading-more {
grid-column: 1 / -1;
padding: 1rem;
font-size: 0.875rem;
}
/* Context Menu */
.sticker-context-menu {
position: fixed;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 0.25rem;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 10000;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
color: #c9d1d9;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.context-menu-item:hover {
background: #30363d;
}
.context-menu-item svg {
flex-shrink: 0;
opacity: 0.7;
}
</style>

View file

@ -0,0 +1,628 @@
<script>
import { audioPlaylist, currentTrack } from '$lib/stores/audioPlaylist';
/** @type {boolean} Whether the audio tab is currently active */
export let isActive = false;
let progressBar;
function formatDuration(seconds) {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function handleProgressClick(e) {
if (!$currentTrack || !$audioPlaylist.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
const newTime = percent * $audioPlaylist.duration;
audioPlaylist.seek(newTime);
}
function handleVolumeChange(e) {
const volume = parseFloat(e.target.value);
audioPlaylist.setVolume(volume);
}
</script>
<div class="audio-tab">
<!-- Player Section (terminal/pixel style) -->
<div class="player-section">
{#if $currentTrack}
<div class="player-header">
<span class="player-label">NOW PLAYING</span>
<span class="player-status">{$audioPlaylist.isPlaying ? '▶' : '■'}</span>
</div>
<div class="player-track-info">
<div class="player-thumb">
{#if $currentTrack.thumbnailPath}
<img src={$currentTrack.thumbnailPath} alt="" />
{:else}
<span class="placeholder"></span>
{/if}
</div>
<div class="player-info">
<span class="player-title">{$currentTrack.title}</span>
<span class="player-artist">{$currentTrack.username}</span>
</div>
</div>
<!-- Progress bar (pixel style) -->
<div class="player-progress-wrap">
<span class="time-display">{formatDuration($audioPlaylist.currentTime)}</span>
<div class="player-progress" on:click={handleProgressClick} bind:this={progressBar}>
<div class="progress-track">
<div class="progress-fill" style="width: {$audioPlaylist.duration ? ($audioPlaylist.currentTime / $audioPlaylist.duration * 100) : 0}%"></div>
<div class="progress-head" style="left: {$audioPlaylist.duration ? ($audioPlaylist.currentTime / $audioPlaylist.duration * 100) : 0}%"></div>
</div>
</div>
<span class="time-display">{formatDuration($audioPlaylist.duration)}</span>
</div>
<!-- Controls (ASCII style) -->
<div class="player-controls">
<button class="ctrl-btn" class:active={$audioPlaylist.shuffle} on:click={() => audioPlaylist.toggleShuffle()} title="Shuffle">
<span class="ctrl-icon"></span>
</button>
<button class="ctrl-btn" on:click={() => audioPlaylist.previous()} title="Previous">
<span class="ctrl-icon">◂◂</span>
</button>
<button class="ctrl-btn play" on:click={() => audioPlaylist.togglePlay()}>
<span class="ctrl-icon">{$audioPlaylist.isPlaying ? '▮▮' : '▶'}</span>
</button>
<button class="ctrl-btn" on:click={() => audioPlaylist.next()} title="Next">
<span class="ctrl-icon">▸▸</span>
</button>
<button class="ctrl-btn" class:active={$audioPlaylist.repeat !== 'none'} on:click={() => audioPlaylist.cycleRepeat()} title="Repeat: {$audioPlaylist.repeat}">
<span class="ctrl-icon">{$audioPlaylist.repeat === 'one' ? '↺¹' : '↺'}</span>
</button>
</div>
<!-- Volume (pixel bar style) -->
<div class="player-volume">
<button class="vol-btn" on:click={() => audioPlaylist.toggleMute()}>
{$audioPlaylist.muted || $audioPlaylist.volume === 0 ? '◁' : '◀'}
</button>
<div class="vol-bar-wrap">
<input
type="range"
min="0"
max="1"
step="0.01"
value={$audioPlaylist.muted ? 0 : $audioPlaylist.volume}
on:input={handleVolumeChange}
class="volume-slider"
style="--volume-percent: {($audioPlaylist.muted ? 0 : $audioPlaylist.volume) * 100}%"
/>
</div>
<span class="vol-pct">{Math.round(($audioPlaylist.muted ? 0 : $audioPlaylist.volume) * 100)}%</span>
</div>
{:else}
<div class="player-empty">
<div class="empty-icon">[ ♫ ]</div>
<span>No track loaded</span>
<span class="player-hint">Browse <a href="/audio">/audio</a> to add tracks</span>
</div>
{/if}
</div>
<!-- Queue display -->
{#if $audioPlaylist.queue.length > 0}
<div class="queue-section">
<div class="queue-header">
<span>Queue ({$audioPlaylist.queue.length} tracks)</span>
<button class="clear-btn" on:click={() => audioPlaylist.clearQueue()}>Clear</button>
</div>
<div class="queue-list">
{#each $audioPlaylist.queue as track, index}
<div
class="queue-item"
class:active={index === $audioPlaylist.currentIndex && !$audioPlaylist.nowPlaying}
on:click={() => audioPlaylist.goToTrack(index)}
>
<span class="queue-index">{index + 1}</span>
<span class="queue-title">{track.title}</span>
<span class="queue-duration">{formatDuration(track.durationSeconds)}</span>
<button class="remove-btn" on:click|stopPropagation={() => audioPlaylist.removeTrack(track.id)}>×</button>
</div>
{/each}
</div>
</div>
{:else}
<div class="queue-empty">
<span>Queue is empty</span>
<span class="queue-hint">Add tracks from <a href="/audio">/audio</a> pages</span>
</div>
{/if}
</div>
<style>
.audio-tab {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d1117;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
/* Player section (terminal style) */
.player-section {
padding: 0.75rem;
background: #0d1117;
border-bottom: 1px solid #21262d;
}
.player-header {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding-bottom: 0.35rem;
border-bottom: 1px dashed #30363d;
}
.player-label {
font-size: 0.65rem;
color: #ec4899;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.player-status {
font-size: 0.7rem;
color: #ec4899;
}
.player-track-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.6rem;
}
.player-thumb {
width: 36px;
height: 36px;
overflow: hidden;
background: #161b22;
border: 1px solid #30363d;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.player-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
image-rendering: pixelated;
}
.player-thumb .placeholder {
font-size: 1rem;
color: #484f58;
}
.player-info {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.1rem;
}
.player-title {
font-size: 0.75rem;
color: #e6edf3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-artist {
font-size: 0.65rem;
color: #6e7681;
cursor: pointer;
transition: filter 0.15s ease;
}
.player-artist:hover {
filter: invert(1);
}
/* Progress bar (pixel style) */
.player-progress-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.time-display {
font-size: 0.65rem;
color: #8b949e;
min-width: 32px;
}
.time-display:last-child {
text-align: right;
}
.player-progress {
flex: 1;
height: 8px;
background: #161b22;
border: 1px solid #30363d;
cursor: pointer;
position: relative;
}
.progress-track {
position: absolute;
inset: 2px;
background: #21262d;
}
.progress-fill {
height: 100%;
background: #ec4899;
transition: width 0.1s linear;
box-shadow: 0 0 4px rgba(236, 72, 153, 0.5);
}
.progress-head {
position: absolute;
top: -1px;
width: 2px;
height: calc(100% + 2px);
background: #f472b6;
transform: translateX(-50%);
box-shadow: 0 0 6px rgba(236, 72, 153, 0.8);
}
/* Player controls (ASCII style) */
.player-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.ctrl-btn {
width: 28px;
height: 24px;
border: 1px solid #30363d;
background: #161b22;
color: #8b949e;
font-size: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s ease;
}
.ctrl-btn:hover {
background: #21262d;
color: #e6edf3;
border-color: #484f58;
}
.ctrl-btn:active {
background: #30363d;
}
.ctrl-btn.active {
background: rgba(236, 72, 153, 0.15);
border-color: #ec4899;
color: #ec4899;
}
.ctrl-btn.play {
width: 36px;
height: 26px;
background: #161b22;
border-color: #ec4899;
color: #ec4899;
}
.ctrl-btn.play:hover {
background: rgba(236, 72, 153, 0.2);
color: #f472b6;
}
.ctrl-icon {
font-family: inherit;
letter-spacing: -1px;
}
/* Volume control (pixel style) */
.player-volume {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
.vol-btn {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: #6e7681;
font-size: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.vol-btn:hover {
color: #ec4899;
}
.vol-bar-wrap {
position: relative;
width: 70px;
height: 16px;
display: flex;
align-items: center;
}
.vol-fill {
display: none;
}
.volume-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #30363d;
border-radius: 2px;
cursor: pointer;
outline: none;
}
.volume-slider::-webkit-slider-runnable-track {
height: 4px;
background: linear-gradient(to right, #ec4899 0%, #ec4899 var(--volume-percent, 100%), #30363d var(--volume-percent, 100%), #30363d 100%);
border-radius: 2px;
}
.volume-slider::-moz-range-track {
height: 4px;
background: #30363d;
border-radius: 2px;
}
.volume-slider::-moz-range-progress {
height: 4px;
background: #ec4899;
border-radius: 2px;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
margin-top: -4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
transition: transform 0.1s ease;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.volume-slider::-webkit-slider-thumb:active {
transform: scale(1.1);
background: #ec4899;
}
.vol-pct {
font-size: 0.6rem;
color: #6e7681;
min-width: 28px;
text-align: right;
}
/* Empty player state */
.player-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
color: #6e7681;
font-size: 0.75rem;
gap: 0.25rem;
}
.empty-icon {
font-size: 0.85rem;
color: #484f58;
margin-bottom: 0.25rem;
}
.player-hint {
font-size: 0.65rem;
color: #484f58;
}
/* Queue section (terminal list style) */
.queue-section {
border-bottom: 1px solid #21262d;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.75rem;
background: #161b22;
border-bottom: 1px solid #21262d;
font-size: 0.65rem;
color: #6e7681;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.clear-btn {
padding: 0.15rem 0.35rem;
background: transparent;
border: 1px solid #30363d;
color: #6e7681;
font-size: 0.6rem;
font-family: inherit;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.clear-btn:hover {
border-color: #f85149;
color: #f85149;
}
.queue-list {
max-height: 120px;
overflow-y: auto;
}
.queue-list::-webkit-scrollbar {
width: 6px;
}
.queue-list::-webkit-scrollbar-track {
background: #0d1117;
}
.queue-list::-webkit-scrollbar-thumb {
background: #30363d;
}
.queue-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.75rem;
font-size: 0.7rem;
color: #8b949e;
cursor: pointer;
border-bottom: 1px solid #161b22;
}
.queue-item:hover {
background: #161b22;
}
.queue-item.active {
background: rgba(236, 72, 153, 0.1);
border-left: 2px solid #ec4899;
padding-left: calc(0.75rem - 2px);
}
.queue-index {
width: 18px;
text-align: right;
font-size: 0.6rem;
color: #484f58;
}
.queue-item.active .queue-index {
color: #ec4899;
}
.queue-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #c9d1d9;
}
.queue-item.active .queue-title {
color: #ec4899;
}
.queue-duration {
font-size: 0.6rem;
color: #484f58;
}
.remove-btn {
width: 16px;
height: 16px;
border: none;
background: transparent;
color: #484f58;
font-size: 0.8rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.1s ease;
}
.queue-item:hover .remove-btn {
opacity: 1;
}
.remove-btn:hover {
color: #f85149;
}
/* Queue empty state */
.queue-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: #6e7681;
font-size: 0.75rem;
flex: 1;
gap: 0.25rem;
}
.queue-hint {
font-size: 0.65rem;
color: #484f58;
}
.queue-hint a,
.player-hint a {
color: #ec4899;
text-decoration: none;
}
.queue-hint a:hover,
.player-hint a:hover {
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,391 @@
<script>
import { onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { ebookReader } from '$lib/stores/ebookReader';
/** @type {boolean} Whether the ebooks tab is currently active */
export let isActive = false;
let ebooks = [];
let loading = true;
let error = null;
let page = 1;
let hasMore = true;
let loadingMore = false;
let refreshInterval = null;
function timeAgo(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h';
if (seconds < 604800) return Math.floor(seconds / 86400) + 'd';
return date.toLocaleDateString();
}
async function loadEbooks(append = false) {
if (!browser) return;
try {
error = null;
const res = await fetch(`/api/ebooks?page=${page}&limit=20`);
if (res.ok) {
const data = await res.json();
const fetchedEbooks = data.ebooks || [];
if (append) {
ebooks = [...ebooks, ...fetchedEbooks];
} else {
ebooks = fetchedEbooks;
}
hasMore = fetchedEbooks.length >= 20;
} else {
error = 'Failed to load ebooks';
}
} catch (e) {
console.error('Failed to load ebooks:', e);
error = 'Failed to load ebooks';
} finally {
loading = false;
loadingMore = false;
}
}
function loadMore() {
if (loadingMore || !hasMore) return;
loadingMore = true; // Set immediately to prevent race condition
page++;
loadEbooks(true);
}
function startRefresh() {
// Clear any existing interval first to prevent stacking
stopRefresh();
page = 1;
loading = true;
loadEbooks();
// Refresh every 30 seconds (ebooks don't change as often as streams)
refreshInterval = setInterval(() => {
page = 1;
loadEbooks();
}, 30000);
}
function stopRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
// Start/stop refresh based on active state
$: if (browser && isActive) {
startRefresh();
} else {
stopRefresh();
}
function openBook(ebook) {
ebookReader.openBook({
id: ebook.id,
title: ebook.title,
filePath: ebook.filePath,
coverPath: ebook.coverPath,
chapterCount: ebook.chapterCount,
username: ebook.username,
realmName: ebook.realmName
});
}
function handleScroll(e) {
const target = e.target;
const nearBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 100;
if (nearBottom && hasMore && !loadingMore) {
loadMore();
}
}
onDestroy(() => {
stopRefresh();
});
</script>
<div class="ebooks-tab">
<div class="ebooks-header">
<span class="ebooks-title">eBooks</span>
<span class="ebook-count">{ebooks.length} loaded</span>
</div>
<div class="ebooks-list" on:scroll={handleScroll}>
{#if loading}
<div class="ebooks-loading">Loading ebooks...</div>
{:else if error}
<div class="ebooks-error">{error}</div>
{:else if ebooks.length === 0}
<div class="ebooks-empty">
<div class="empty-icon">[ ◇ ]</div>
<span>No ebooks found</span>
<span class="empty-hint">Browse <a href="/ebooks">/ebooks</a> to upload</span>
</div>
{:else}
{#each ebooks as ebook (ebook.id)}
<div class="ebook-item">
<div class="ebook-cover">
{#if ebook.coverPath}
<img src={ebook.coverPath} alt={ebook.title} />
{:else}
<span class="cover-placeholder"></span>
{/if}
</div>
<div class="ebook-info">
<span class="ebook-title">{ebook.title}</span>
<span class="ebook-meta">
<span class="ebook-realm">{ebook.realmName}</span>
{#if ebook.chapterCount}
<span class="ebook-stats">{ebook.chapterCount} chapters</span>
{/if}
</span>
<span class="ebook-sub">
<span class="ebook-user">@{ebook.username}</span>
<span class="ebook-time">{timeAgo(ebook.createdAt)}</span>
</span>
</div>
<button
class="read-btn"
on:click={() => openBook(ebook)}
title="Open in reader"
>Read</button>
</div>
{/each}
{#if loadingMore}
<div class="loading-more">Loading more...</div>
{/if}
{/if}
</div>
</div>
<style>
.ebooks-tab {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d1117;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
.ebooks-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #30363d;
}
.ebooks-title {
color: #3b82f6;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.ebook-count {
color: #6e7681;
font-size: 0.65rem;
}
.ebooks-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.ebooks-list::-webkit-scrollbar {
width: 6px;
}
.ebooks-list::-webkit-scrollbar-track {
background: #0d1117;
}
.ebooks-list::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 3px;
}
.ebooks-loading,
.loading-more {
color: #8b949e;
font-size: 0.75rem;
text-align: center;
padding: 1.5rem 1rem;
}
.ebooks-error {
color: #f85149;
font-size: 0.75rem;
text-align: center;
padding: 1.5rem 1rem;
}
.ebooks-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: #6e7681;
font-size: 0.75rem;
gap: 0.25rem;
flex: 1;
}
.empty-icon {
font-size: 0.85rem;
color: #3b82f6;
margin-bottom: 0.25rem;
}
.empty-hint {
font-size: 0.65rem;
color: #484f58;
}
.empty-hint a {
color: #3b82f6;
text-decoration: none;
}
.empty-hint a:hover {
text-decoration: underline;
}
.ebook-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem;
border-radius: 4px;
transition: background 0.15s ease;
border-bottom: 1px solid #161b22;
}
.ebook-item:hover {
background: rgba(59, 130, 246, 0.05);
}
.ebook-item:last-child {
border-bottom: none;
}
.ebook-cover {
width: 36px;
height: 54px;
border-radius: 2px;
overflow: hidden;
background: #161b22;
border: 1px solid #30363d;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ebook-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
font-size: 0.9rem;
color: #3b82f6;
opacity: 0.5;
}
.ebook-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.ebook-title {
color: #c9d1d9;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ebook-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.65rem;
}
.ebook-realm {
color: #3b82f6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 80px;
}
.ebook-stats {
color: #6e7681;
}
.ebook-sub {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.6rem;
color: #484f58;
}
.ebook-user {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: filter 0.15s ease;
}
.ebook-user:hover {
filter: invert(1);
}
.ebook-time {
white-space: nowrap;
}
.read-btn {
padding: 0.3rem 0.6rem;
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 4px;
color: #3b82f6;
font-size: 0.65rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.read-btn:hover {
background: rgba(59, 130, 246, 0.25);
border-color: #3b82f6;
}
.read-btn:active {
background: rgba(59, 130, 246, 0.35);
}
</style>

View file

@ -0,0 +1,495 @@
<script>
import { onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { nakama, GAMES_POLL_INTERVAL } from '$lib/stores/nakama';
import { gamesOverlay } from '$lib/stores/gamesOverlay';
import { isAuthenticated, auth } from '$lib/stores/auth';
/** @type {boolean} Whether the games tab is currently active */
export let isActive = false;
let waitingMatches = [];
let liveMatches = [];
let loading = true;
let refreshInterval = null;
let creatingMatch = false;
function timeAgo(timestamp) {
if (!timestamp) return '';
const now = Math.floor(Date.now() / 1000);
const seconds = now - timestamp;
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h';
return Math.floor(seconds / 86400) + 'd';
}
async function loadMatches() {
if (!browser) return;
try {
// Initialize nakama if needed
await nakama.init();
const authResult = await nakama.authenticate();
if (!authResult.success) {
loading = false;
return;
}
// Load both waiting and playing matches
const [waitingResult, playingResult] = await Promise.all([
nakama.listChessMatches(10, 'waiting'),
nakama.listChessMatches(10, 'playing')
]);
if (waitingResult.success) {
waitingMatches = waitingResult.matches || [];
}
if (playingResult.success) {
liveMatches = playingResult.matches || [];
}
} catch (e) {
console.error('Failed to load matches:', e);
} finally {
loading = false;
}
}
function startRefresh() {
stopRefresh();
loading = true;
loadMatches();
refreshInterval = setInterval(loadMatches, GAMES_POLL_INTERVAL);
}
function stopRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
// Start/stop refresh based on active state
$: if (browser && isActive) {
startRefresh();
} else {
stopRefresh();
}
async function createNewGame() {
if (creatingMatch) return;
creatingMatch = true;
try {
const result = await nakama.createChallenge();
if (result.success) {
// Optimistic update - add to list immediately
const newMatch = {
matchId: result.matchId,
white: $auth.user?.username || 'You',
black: null,
status: 'waiting',
createdAt: Math.floor(Date.now() / 1000),
isCurrentUserMatch: true
};
waitingMatches = [newMatch, ...waitingMatches];
// Background refresh to sync with server
loadMatches();
} else {
console.error('Failed to create challenge:', result.error);
}
} catch (e) {
console.error('Failed to create match:', e);
} finally {
creatingMatch = false;
}
}
async function joinGame(matchId) {
try {
await nakama.init();
const authResult = await nakama.authenticate();
if (!authResult.success) return;
await nakama.connectSocket();
const result = await nakama.joinMatch(matchId);
if (result.success) {
gamesOverlay.openGame(matchId, 'playing');
}
} catch (e) {
console.error('Failed to join match:', e);
}
}
async function watchGame(matchId) {
try {
await nakama.init();
const authResult = await nakama.authenticate();
if (!authResult.success) return;
await nakama.connectSocket();
const result = await nakama.joinMatch(matchId);
if (result.success) {
gamesOverlay.openGame(matchId, 'spectating');
}
} catch (e) {
console.error('Failed to watch match:', e);
}
}
async function cancelChallenge(matchId) {
// Optimistic update - remove from list immediately
waitingMatches = waitingMatches.filter(m => m.matchId !== matchId);
try {
const result = await nakama.cancelChallenge(matchId);
if (!result.success) {
console.error('Failed to cancel challenge:', result.error);
loadMatches(); // Refresh to restore correct state
}
} catch (e) {
console.error('Failed to cancel challenge:', e);
loadMatches(); // Refresh to restore correct state
}
}
onDestroy(() => {
stopRefresh();
});
</script>
<div class="games-tab">
<div class="games-header">
<span class="games-title">Chess960</span>
{#if $isAuthenticated}
<button
class="create-btn"
on:click={createNewGame}
disabled={creatingMatch}
>
{creatingMatch ? '...' : '+ Challenge'}
</button>
{:else}
<span class="login-hint">Login to play</span>
{/if}
</div>
<div class="games-list">
{#if loading}
<div class="games-loading">Loading games...</div>
{:else}
<!-- Waiting matches (open challenges) -->
<div class="section-header">Open Challenges</div>
{#if waitingMatches.length === 0}
<div class="empty-section">No open challenges</div>
{:else}
{#each waitingMatches as match (match.matchId)}
<div class="game-item waiting" class:own-match={match.isCurrentUserMatch}>
<div class="game-info">
<span class="player-name">{match.white || 'Unknown'}</span>
{#if match.isCurrentUserMatch}
<span class="own-badge">you</span>
{:else}
<span class="waiting-text">waiting...</span>
{/if}
<span class="game-time">{timeAgo(match.createdAt)}</span>
</div>
<div class="game-actions">
{#if $isAuthenticated}
{#if match.isCurrentUserMatch}
<button
class="action-btn view"
on:click={() => watchGame(match.matchId)}
>View</button>
<button
class="action-btn cancel"
on:click={() => cancelChallenge(match.matchId)}
>Cancel</button>
{:else}
<button
class="action-btn join"
on:click={() => joinGame(match.matchId)}
>Join</button>
{/if}
{/if}
{#if !match.isCurrentUserMatch}
<button
class="action-btn popout"
on:click={() => watchGame(match.matchId)}
title="Open in popout"
>⧉</button>
{/if}
</div>
</div>
{/each}
{/if}
<!-- Live matches -->
<div class="section-header">Live Games</div>
{#if liveMatches.length === 0}
<div class="empty-section">No live games</div>
{:else}
{#each liveMatches as match (match.matchId)}
<div class="game-item live">
<div class="game-info">
<span class="player-name">{match.white}</span>
<span class="vs">vs</span>
<span class="player-name">{match.black}</span>
{#if match.spectatorCount > 0}
<span class="spectators" title="{match.spectatorCount} watching">
{match.spectatorCount}
</span>
{/if}
</div>
<button
class="action-btn popout"
on:click={() => watchGame(match.matchId)}
title="Watch in popout"
>⧉</button>
</div>
{/each}
{/if}
{/if}
</div>
</div>
<style>
.games-tab {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d1117;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
.games-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #30363d;
}
.games-title {
color: #f59e0b;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.create-btn {
padding: 0.3rem 0.6rem;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 4px;
color: #f59e0b;
font-size: 0.65rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.create-btn:hover:not(:disabled) {
background: rgba(245, 158, 11, 0.25);
border-color: #f59e0b;
}
.create-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.game-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.login-hint {
color: #6e7681;
font-size: 0.65rem;
}
.games-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.games-list::-webkit-scrollbar {
width: 6px;
}
.games-list::-webkit-scrollbar-track {
background: #0d1117;
}
.games-list::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 3px;
}
.games-loading {
color: #8b949e;
font-size: 0.75rem;
text-align: center;
padding: 1.5rem 1rem;
}
.section-header {
color: #6e7681;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 0.25rem 0.25rem;
margin-top: 0.5rem;
border-bottom: 1px solid #21262d;
}
.section-header:first-child {
margin-top: 0;
}
.empty-section {
color: #484f58;
font-size: 0.7rem;
padding: 0.5rem 0.25rem;
font-style: italic;
}
.game-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.25rem;
border-radius: 4px;
transition: background 0.15s ease;
}
.game-item:hover {
background: rgba(245, 158, 11, 0.05);
}
.game-info {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 0;
font-size: 0.7rem;
}
.player-name {
color: #c9d1d9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 80px;
}
.vs {
color: #6e7681;
font-size: 0.6rem;
}
.waiting-text {
color: #f59e0b;
font-size: 0.6rem;
opacity: 0.8;
}
.own-badge {
color: #22c55e;
font-size: 0.6rem;
font-weight: 600;
}
.game-item.own-match {
background: rgba(34, 197, 94, 0.05);
border-left: 2px solid #22c55e;
padding-left: calc(0.25rem - 2px);
}
.game-time {
color: #484f58;
font-size: 0.6rem;
}
.spectators {
color: #6e7681;
font-size: 0.6rem;
padding: 0.1rem 0.3rem;
background: rgba(110, 118, 129, 0.15);
border-radius: 3px;
}
.spectators::before {
content: '\1F441 ';
}
.action-btn {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.6rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.action-btn.join {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
.action-btn.join:hover {
background: rgba(245, 158, 11, 0.25);
border-color: #f59e0b;
}
.action-btn.view {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #22c55e;
}
.action-btn.view:hover {
background: rgba(34, 197, 94, 0.25);
border-color: #22c55e;
}
.action-btn.cancel {
background: rgba(248, 81, 73, 0.15);
border: 1px solid rgba(248, 81, 73, 0.3);
color: #f85149;
}
.action-btn.cancel:hover {
background: rgba(248, 81, 73, 0.25);
border-color: #f85149;
}
.action-btn.popout {
background: rgba(110, 118, 129, 0.15);
border: 1px solid rgba(110, 118, 129, 0.3);
color: #f59e0b;
padding: 0.25rem 0.35rem;
}
.action-btn.popout:hover {
background: rgba(245, 158, 11, 0.15);
border-color: #f59e0b;
}
</style>

View file

@ -0,0 +1,448 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { streamTiles } from '$lib/stores/streamTiles';
/** @type {boolean} Whether the streams tab is currently active */
export let isActive = false;
let allRealms = [];
let loading = true;
let hoveredStream = null;
let refreshInterval = null;
$: liveRealms = allRealms.filter(r => r.isLive);
$: offlineRealms = allRealms.filter(r => !r.isLive);
// Get the current realm from URL if on a live page (e.g., /realmname/live)
$: currentLiveRealm = (() => {
const path = $page.url.pathname;
const match = path.match(/^\/([^/]+)\/live$/);
return match ? match[1] : null;
})();
// Reactive set of stream keys currently in tiles (for proper reactivity)
// Use streamKey if available, otherwise fall back to realm id
$: tiledStreamKeys = new Set($streamTiles.streams.map(s => s.streamKey || `realm-${s.realmId}`));
// Get a consistent identifier for a stream
function getStreamId(stream) {
return stream.streamKey || `realm-${stream.id}`;
}
async function loadAllRealms() {
if (!browser) return;
try {
const res = await fetch('/api/realms/all');
if (res.ok) {
const data = await res.json();
allRealms = data.realms || [];
}
} catch (e) {
console.error('Failed to load realms:', e);
} finally {
loading = false;
}
}
function startRefresh() {
// Clear any existing interval first to prevent stacking
stopRefresh();
loadAllRealms();
refreshInterval = setInterval(loadAllRealms, 10000);
}
function stopRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
// Start/stop refresh based on active state
$: if (browser && isActive) {
startRefresh();
} else {
stopRefresh();
}
function addToTile(stream) {
streamTiles.addStream({
streamKey: getStreamId(stream),
name: stream.name,
username: stream.username,
realmId: stream.id,
offlineImageUrl: stream.offlineImageUrl || null
});
}
function removeFromTile(streamKey) {
streamTiles.removeStream(streamKey);
}
function toggleTile(stream, isTiled, isViewing) {
if (isViewing) return;
if (isTiled) {
removeFromTile(getStreamId(stream));
} else {
addToTile(stream);
}
}
onDestroy(() => {
stopRefresh();
});
</script>
<div class="streams-tab">
<div class="streams-header">
<span class="streams-title">Realms</span>
{#if $streamTiles.streams.length > 0}
<button class="tile-toggle" on:click={() => streamTiles.toggle()}>
{$streamTiles.enabled ? 'Hide Tiles' : 'Show Tiles'} ({$streamTiles.streams.length})
</button>
{/if}
</div>
<div class="streams-list">
{#if loading}
<div class="streams-loading">Loading realms...</div>
{:else if allRealms.length === 0}
<div class="streams-empty">No realms found</div>
{:else}
{#if liveRealms.length > 0}
<div class="streams-section-header">Live ({liveRealms.length})</div>
{#each liveRealms as stream (stream.id)}
{@const isViewing = currentLiveRealm && stream.name.toLowerCase() === currentLiveRealm.toLowerCase()}
{@const isTiled = tiledStreamKeys.has(getStreamId(stream))}
<div
class="stream-item"
on:mouseenter={() => hoveredStream = stream.streamKey}
on:mouseleave={() => hoveredStream = null}
>
<a href={`/${stream.name}/live`} class="stream-link" target="_blank">
<div class="stream-preview">
{#if hoveredStream === stream.streamKey && stream.streamKey}
<img src={`/thumb/${stream.streamKey}.webp`} alt={stream.name} />
{:else}
<div class="preview-placeholder live">{stream.name.charAt(0).toUpperCase()}</div>
{/if}
</div>
<div class="stream-info">
<span class="stream-name">{stream.name}</span>
<span class="stream-meta">
<span class="viewer-count">{stream.viewerCount || 0}</span>
<span class="stream-user">@{stream.username}</span>
</span>
</div>
</a>
<button
class="tile-btn"
class:remove={isTiled && !isViewing}
class:viewing={isViewing}
on:click|stopPropagation={() => toggleTile(stream, isTiled, isViewing)}
title={isViewing ? 'Currently viewing' : isTiled ? 'Remove from tiles' : 'Add to tiles'}
disabled={isViewing}
>{isViewing ? '*' : isTiled ? '' : '+'}</button>
</div>
{/each}
{/if}
{#if offlineRealms.length > 0}
<div class="streams-section-header">Offline ({offlineRealms.length})</div>
{#each offlineRealms as stream (stream.id)}
{@const isViewing = currentLiveRealm && stream.name.toLowerCase() === currentLiveRealm.toLowerCase()}
{@const isTiled = tiledStreamKeys.has(getStreamId(stream))}
<div
class="stream-item offline"
on:mouseenter={() => hoveredStream = stream.streamKey}
on:mouseleave={() => hoveredStream = null}
>
<a href={`/${stream.name}/live`} class="stream-link" target="_blank">
<div class="stream-preview">
{#if stream.offlineImageUrl}
<img src={stream.offlineImageUrl} alt={stream.name} />
{:else}
<div class="preview-placeholder offline">{stream.name.charAt(0).toUpperCase()}</div>
{/if}
</div>
<div class="stream-info">
<span class="stream-name">{stream.name}</span>
<span class="stream-meta">
<span class="offline-badge">Offline</span>
<span class="stream-user">@{stream.username}</span>
</span>
</div>
</a>
<button
class="tile-btn"
class:remove={isTiled && !isViewing}
class:viewing={isViewing}
on:click|stopPropagation={() => toggleTile(stream, isTiled, isViewing)}
title={isViewing ? 'Currently viewing' : isTiled ? 'Remove from tiles' : 'Add to tiles'}
disabled={isViewing}
>{isViewing ? '*' : isTiled ? '' : '+'}</button>
</div>
{/each}
{/if}
{/if}
</div>
</div>
<style>
.streams-tab {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d1117;
}
.streams-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #30363d;
}
.streams-title {
color: #8b949e;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tile-toggle {
padding: 0.25rem 0.5rem;
background: rgba(126, 231, 135, 0.15);
border: 1px solid rgba(126, 231, 135, 0.3);
border-radius: 4px;
color: #7ee787;
font-size: 0.7rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.tile-toggle:hover {
background: rgba(126, 231, 135, 0.25);
}
.streams-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.streams-list::-webkit-scrollbar {
width: 6px;
}
.streams-list::-webkit-scrollbar-track {
background: #0d1117;
}
.streams-list::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 3px;
}
.streams-loading,
.streams-empty {
color: #8b949e;
font-size: 0.8rem;
text-align: center;
padding: 2rem 1rem;
}
.streams-section-header {
font-size: 0.7rem;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.5rem 0.5rem 0.25rem;
border-bottom: 1px solid #30363d;
margin-top: 0.5rem;
}
.streams-section-header:first-child {
margin-top: 0;
}
.stream-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem;
border-radius: 4px;
transition: background 0.15s ease;
}
.stream-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.stream-item.offline {
opacity: 0.7;
}
.stream-item.offline:hover {
opacity: 1;
}
.stream-link {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
text-decoration: none;
color: inherit;
min-width: 0;
}
.stream-preview {
width: 48px;
height: 27px;
border-radius: 3px;
overflow: hidden;
background: #1a1a1a;
flex-shrink: 0;
}
.stream-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #561d5e, #8b3a92);
color: white;
font-size: 0.75rem;
font-weight: 600;
}
.preview-placeholder.live {
background: linear-gradient(135deg, #238636, #2ea043);
}
.preview-placeholder.offline {
background: linear-gradient(135deg, #30363d, #484f58);
}
.stream-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.stream-name {
color: #c9d1d9;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stream-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.7rem;
color: #8b949e;
}
.viewer-count {
color: #f85149;
font-weight: 500;
}
.viewer-count::before {
content: '●';
margin-right: 0.2rem;
font-size: 0.5rem;
vertical-align: middle;
}
.stream-user {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: filter 0.15s ease;
}
.stream-user:hover {
filter: invert(1);
}
.offline-badge {
color: #8b949e;
font-size: 0.65rem;
background: rgba(139, 148, 158, 0.2);
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.tile-btn {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #30363d;
background: rgba(255, 255, 255, 0.05);
color: #8b949e;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
flex-shrink: 0;
}
.tile-btn:hover {
background: rgba(126, 231, 135, 0.15);
border-color: rgba(126, 231, 135, 0.3);
color: #7ee787;
}
.tile-btn.active {
background: rgba(126, 231, 135, 0.2);
border-color: #7ee787;
color: #7ee787;
}
.tile-btn.remove {
background: rgba(248, 81, 73, 0.15);
border-color: rgba(248, 81, 73, 0.3);
color: #f85149;
}
.tile-btn.remove:hover {
background: rgba(248, 81, 73, 0.25);
border-color: #f85149;
color: #f85149;
}
.tile-btn.viewing,
.tile-btn:disabled {
background: rgba(139, 92, 246, 0.2);
border-color: rgba(139, 92, 246, 0.4);
color: #a78bfa;
cursor: default;
}
.tile-btn:disabled:hover {
background: rgba(139, 92, 246, 0.2);
border-color: rgba(139, 92, 246, 0.4);
color: #a78bfa;
}
</style>

View file

@ -0,0 +1,383 @@
<script>
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
import { browser } from '$app/environment';
import { filteredMessages, connectionStatus, chatUserInfo, fetchRealmStats, availableRealms } 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';
import { parseCommand, executeCommand } from './terminalCommands.js';
const dispatch = createEventDispatcher();
/** @type {string|number|null} Currently connected realm ID */
export let realmId = null;
/** @type {boolean} Whether to render sticker images in messages */
export let renderStickers = false;
/** @type {boolean} Show hotkey help in /help command */
export let showHotkeyHelp = false;
/** @type {string} Terminal hotkey to display in help */
export let terminalHotkey = '`';
/** @type {boolean} Whether the terminal tab is active (for auto-scroll) */
export let isActive = true;
let terminalInput = '';
let inputElement;
let autoScrollEnabled = true;
let commandHistory = [];
let historyIndex = -1;
let systemMessages = [];
let scrollTimeout = null;
let focusTimeout = null;
$: isConnected = $connectionStatus === 'connected';
$: username = $auth.user?.username || $chatUserInfo.username || 'guest';
// Exposed methods
export function addSystemMessage(text) {
systemMessages = [...systemMessages, {
id: Date.now() + Math.random(),
text,
timestamp: new Date().toLocaleTimeString()
}];
tick().then(scrollToBottom);
}
export function scrollToBottom() {
if (!browser || !autoScrollEnabled || !isActive) return;
// Clear any pending scroll to avoid stacking
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const container = document.querySelector('.terminal-messages');
if (container) {
container.scrollTop = container.scrollHeight;
}
scrollTimeout = null;
}, 10);
}
export function focusInput() {
if (inputElement) {
// Clear any pending focus to avoid stacking
if (focusTimeout) clearTimeout(focusTimeout);
focusTimeout = setTimeout(() => {
inputElement?.focus();
focusTimeout = null;
}, 100);
}
}
export function clearMessages() {
systemMessages = [];
}
function handleScroll(event) {
const container = event.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = container;
autoScrollEnabled = scrollTop + clientHeight >= scrollHeight - 50;
}
function handleInputKeyDown(event) {
// Arrow up for history
if (event.key === 'ArrowUp') {
event.preventDefault();
if (commandHistory.length > 0 && historyIndex < commandHistory.length - 1) {
historyIndex++;
terminalInput = commandHistory[commandHistory.length - 1 - historyIndex];
}
}
// Arrow down for history
else if (event.key === 'ArrowDown') {
event.preventDefault();
if (historyIndex > 0) {
historyIndex--;
terminalInput = commandHistory[commandHistory.length - 1 - historyIndex];
} else if (historyIndex === 0) {
historyIndex = -1;
terminalInput = '';
}
}
}
async function handleCommand(event) {
event.preventDefault();
const input = terminalInput.trim();
if (!input) return;
// Add to history
commandHistory.push(input);
historyIndex = -1;
// Parse commands
if (input.startsWith('/')) {
const parsed = parseCommand(input);
if (parsed) {
await executeCommand(parsed.command, parsed.args, {
addSystemMessage,
chatWebSocket,
authUser: $auth.user,
userColor: $userColor,
realmId,
isConnected,
clearMessages,
toggleStickers: () => {
renderStickers = !renderStickers;
dispatch('stickersToggled', { renderStickers });
},
renderStickers,
setRealmId: (id) => {
realmId = id;
dispatch('realmChange', { realmId: id });
},
showHotkeyHelp,
terminalHotkey
});
}
} else {
// Send as chat message
if (realmId && isConnected) {
chatWebSocket.sendMessage(input, $userColor);
} else {
addSystemMessage('Not connected to any realm. Use /join <realm> to connect.');
}
}
terminalInput = '';
}
function handleShowProfile(event) {
dispatch('showProfile', event.detail);
}
// Auto-scroll when messages change
$: if (browser) {
systemMessages.length;
$filteredMessages.length;
if (isActive) {
scrollToBottom();
}
}
// Scroll to bottom when terminal becomes active (opened)
$: if (browser && isActive) {
tick().then(scrollToBottom);
}
// Cleanup timeouts on destroy to prevent memory leaks
onDestroy(() => {
if (scrollTimeout) clearTimeout(scrollTimeout);
if (focusTimeout) clearTimeout(focusTimeout);
});
// Auto-connect to global chat on mount (like chat panel)
onMount(async () => {
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
// If already connected, just use that connection
if (isConnected) {
// Already connected (e.g., from chat panel on stream page)
} else if (realmId) {
// Connect to the provided realm
chatWebSocket.connect(realmId, token);
} else {
// Auto-connect to global chat: fetch realms and connect to one
try {
const stats = await fetchRealmStats();
if (stats && stats.length > 0) {
// Connect to the realm with most participants, or first available
const sortedRealms = [...stats].sort((a, b) => b.participantCount - a.participantCount);
const defaultRealm = sortedRealms[0];
realmId = String(defaultRealm.realmId);
chatWebSocket.connect(realmId, token);
dispatch('realmChange', { realmId });
}
} catch (e) {
console.error('Failed to auto-connect terminal:', e);
}
}
// Scroll to bottom when component mounts (terminal opens)
tick().then(scrollToBottom);
});
</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}
<!-- Inline command input at end of messages -->
<form class="terminal-input-line" on:submit={handleCommand}>
<span class="prompt">{username}@realms:~$</span>
<input
type="text"
class="terminal-input"
bind:this={inputElement}
bind:value={terminalInput}
on:keydown={handleInputKeyDown}
placeholder="/help"
/>
</form>
</div>
<style>
.terminal-messages {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
color: #c9d1d9;
background: transparent;
scrollbar-width: thin;
scrollbar-color: #30363d transparent;
}
.terminal-messages::-webkit-scrollbar {
width: 1px;
}
.terminal-messages::-webkit-scrollbar-track {
background: transparent;
}
.terminal-messages::-webkit-scrollbar-thumb {
background: #30363d;
}
/* Inline command input - appears at end of messages */
.terminal-input-line {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.125rem 0;
margin-top: 0.25rem;
}
.prompt {
color: #7ee787;
font-weight: 600;
white-space: nowrap;
font-size: 0.8rem;
}
.terminal-input {
flex: 1;
background: transparent;
border: none;
color: #c9d1d9;
font-family: inherit;
font-size: 0.8rem;
outline: none;
caret-color: #7ee787;
}
.terminal-input::placeholder {
color: #484f58;
}
.terminal-input:disabled {
opacity: 0.5;
}
.system-message {
padding: 0.125rem 0;
color: #8b949e;
font-size: 0.8rem;
line-height: 1.3;
border-left: none;
padding-left: 0;
margin: 0;
}
.system-prefix {
color: #6e7681;
font-size: 0.75rem;
margin-right: 0.5rem;
}
.system-text {
color: #c9d1d9;
}
/* Terminal-style compact messages - override ChatMessage styles */
.terminal-messages :global(.chat-message) {
padding: 0.125rem 0;
margin: 0;
background: transparent;
border-radius: 0;
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.terminal-messages :global(.chat-message:hover) {
background: transparent;
}
.terminal-messages :global(.chat-message .message-header) {
margin-bottom: 0;
gap: 0.35rem;
font-size: 0.8rem;
flex-shrink: 0;
}
.terminal-messages :global(.chat-message .message-content) {
font-size: 0.8rem;
line-height: 1.3;
margin-left: 0;
flex: 1;
min-width: 0;
}
.terminal-messages :global(.chat-message .message-content p) {
margin: 0;
}
.terminal-messages :global(.chat-message .user-avatar),
.terminal-messages :global(.chat-message .user-avatar-placeholder) {
width: 16px;
height: 16px;
font-size: 0.5rem;
}
.terminal-messages :global(.chat-message .timestamp) {
font-size: 0.7rem;
}
.terminal-messages :global(.chat-message .badge) {
font-size: 0.55rem;
padding: 0.05rem 0.25rem;
}
/* Terminal mode: scale graffiti down to match text height like stickers */
.terminal-messages :global(.graffiti-img) {
width: auto !important;
height: 16px !important;
max-width: 100px !important;
max-height: 16px !important;
}
</style>

View file

@ -0,0 +1,172 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
/**
* @typedef {Object} Tab
* @property {string} id - Unique tab identifier
* @property {string} label - Tab display label
* @property {string} [color] - Optional accent color for active state
*/
/** @type {Tab[]} */
export let tabs = [];
/** @type {string} */
export let activeTab = '';
const dispatch = createEventDispatcher();
let tabsContainer;
let showLeftArrow = false;
let showRightArrow = false;
let resizeDebounceTimeout = null;
function handleTabClick(tabId) {
if (tabId !== activeTab) {
dispatch('tabChange', { tab: tabId });
}
}
function checkScrollArrows() {
if (!tabsContainer) return;
const { scrollLeft, scrollWidth, clientWidth } = tabsContainer;
showLeftArrow = scrollLeft > 0;
showRightArrow = scrollLeft + clientWidth < scrollWidth - 1;
}
function scrollLeft() {
if (!tabsContainer) return;
tabsContainer.scrollBy({ left: -100, behavior: 'smooth' });
}
function scrollRight() {
if (!tabsContainer) return;
tabsContainer.scrollBy({ left: 100, behavior: 'smooth' });
}
function debouncedCheckScrollArrows() {
if (resizeDebounceTimeout) clearTimeout(resizeDebounceTimeout);
resizeDebounceTimeout = setTimeout(() => {
checkScrollArrows();
resizeDebounceTimeout = null;
}, 100);
}
onMount(() => {
checkScrollArrows();
// Use ResizeObserver to detect container size changes with debounce
const resizeObserver = new ResizeObserver(debouncedCheckScrollArrows);
resizeObserver.observe(tabsContainer);
return () => resizeObserver.disconnect();
});
onDestroy(() => {
if (resizeDebounceTimeout) clearTimeout(resizeDebounceTimeout);
});
</script>
<div class="tab-bar-wrapper">
<div
class="tab-bar"
bind:this={tabsContainer}
on:scroll={checkScrollArrows}
>
{#each tabs as tab (tab.id)}
<button
class="tab-button"
class:active={activeTab === tab.id}
style={tab.color && activeTab === tab.id ? `--tab-color: ${tab.color}` : ''}
on:click={() => handleTabClick(tab.id)}
>
{tab.label}
</button>
{/each}
</div>
{#if showLeftArrow || showRightArrow}
<button class="scroll-arrow" on:click={showRightArrow ? scrollRight : scrollLeft} title="Scroll tabs">
&lt;&gt;
</button>
{/if}
</div>
<style>
.tab-bar-wrapper {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
gap: 0.375rem;
}
.tab-bar {
display: flex;
gap: 0.375rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
min-width: 0;
flex: 1;
}
.tab-bar::-webkit-scrollbar {
display: none;
}
.scroll-arrow {
display: flex;
align-items: center;
justify-content: center;
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;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.scroll-arrow:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
color: #fff;
}
.tab-button {
display: flex;
align-items: center;
justify-content: center;
height: 26px;
padding: 0 0.5rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #aaa;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.tab-button:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
color: #fff;
}
.tab-button.active {
color: var(--tab-color, #4caf50);
background: rgba(76, 175, 80, 0.15);
border-color: rgba(76, 175, 80, 0.4);
}
/* Support custom tab colors */
.tab-button.active[style*="--tab-color"] {
background: color-mix(in srgb, var(--tab-color) 15%, transparent);
border-color: color-mix(in srgb, var(--tab-color) 40%, transparent);
}
</style>

View file

@ -0,0 +1,350 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { treasury, fetchTreasury, formatUbercoin, getTimeUntilDistribution } from '$lib/stores/ubercoin';
import { auth, isAuthenticated } from '$lib/stores/auth';
export let isActive = false;
let countdownInterval;
let countdown = null;
$: if (isActive) {
loadTreasury();
}
async function loadTreasury() {
await fetchTreasury();
updateCountdown();
}
function updateCountdown() {
countdown = getTimeUntilDistribution($treasury.nextDistribution);
}
onMount(() => {
if (isActive) {
loadTreasury();
}
// Update countdown every minute
countdownInterval = setInterval(() => {
updateCountdown();
}, 60000);
});
onDestroy(() => {
if (countdownInterval) {
clearInterval(countdownInterval);
}
});
function formatCountdown(cd) {
if (!cd) return 'Calculating...';
const parts = [];
if (cd.days > 0) parts.push(`${cd.days}d`);
if (cd.hours > 0) parts.push(`${cd.hours}h`);
parts.push(`${cd.minutes}m`);
return parts.join(' ');
}
// Calculate user's estimated share after their personal burn
function calculatePersonalShare(share, userCreatedAt) {
if (!share || !userCreatedAt) return share;
// This is just an estimate - actual calculation happens on server
return share;
}
</script>
<style>
.treasury-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
overflow-y: auto;
background: #0d1117;
color: #c9d1d9;
}
.treasury-header {
text-align: center;
margin-bottom: 1.5rem;
}
.treasury-title {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 600;
color: #ffd700;
margin-bottom: 0.5rem;
}
.coin-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border-radius: 50%;
font-size: 1rem;
font-weight: bold;
color: #000;
}
.treasury-subtitle {
color: #8b949e;
font-size: 0.85rem;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #30363d;
border-radius: 8px;
padding: 1rem;
}
.stat-card.highlight {
background: rgba(255, 215, 0, 0.05);
border-color: rgba(255, 215, 0, 0.3);
}
.stat-card.full-width {
grid-column: span 2;
}
.stat-label {
font-size: 0.75rem;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: #ffd700;
font-family: 'Consolas', 'Monaco', monospace;
}
.stat-value.destroyed {
color: #f85149;
}
.stat-value.users {
color: #7ee787;
}
.stat-value.countdown {
color: #58a6ff;
font-size: 1.25rem;
}
.stat-suffix {
font-size: 0.9rem;
color: #8b949e;
margin-left: 0.25rem;
}
.countdown-section {
text-align: center;
padding: 1.25rem;
background: rgba(88, 166, 255, 0.08);
border: 1px solid rgba(88, 166, 255, 0.3);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.countdown-label {
font-size: 0.85rem;
color: #8b949e;
margin-bottom: 0.5rem;
}
.countdown-value {
font-size: 1.75rem;
font-weight: 600;
color: #58a6ff;
font-family: 'Consolas', 'Monaco', monospace;
}
.info-section {
background: rgba(255, 255, 255, 0.02);
border: 1px solid #30363d;
border-radius: 8px;
padding: 1rem;
}
.info-title {
font-size: 0.9rem;
font-weight: 600;
color: #c9d1d9;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.info-list {
list-style: none;
padding: 0;
margin: 0;
font-size: 0.85rem;
color: #8b949e;
}
.info-list li {
padding: 0.4rem 0;
padding-left: 1.25rem;
position: relative;
}
.info-list li::before {
content: '>';
position: absolute;
left: 0;
color: #7ee787;
}
.your-share {
margin-top: 1.5rem;
padding: 1rem;
background: rgba(126, 231, 135, 0.08);
border: 1px solid rgba(126, 231, 135, 0.3);
border-radius: 8px;
text-align: center;
}
.your-share-label {
font-size: 0.8rem;
color: #8b949e;
margin-bottom: 0.5rem;
}
.your-share-value {
font-size: 1.25rem;
font-weight: 600;
color: #7ee787;
font-family: 'Consolas', 'Monaco', monospace;
}
.your-share-note {
font-size: 0.75rem;
color: #8b949e;
margin-top: 0.5rem;
}
.refresh-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid #30363d;
border-radius: 4px;
color: #8b949e;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
width: 100%;
}
.refresh-button:hover {
background: rgba(255, 255, 255, 0.05);
color: #c9d1d9;
border-color: #8b949e;
}
/* Minimal 1px grey scrollbar */
.treasury-container::-webkit-scrollbar {
width: 1px;
}
.treasury-container::-webkit-scrollbar-track {
background: transparent;
}
.treasury-container::-webkit-scrollbar-thumb {
background: #30363d;
}
.treasury-container {
scrollbar-width: thin;
scrollbar-color: #30363d transparent;
}
</style>
<div class="treasury-container">
<div class="treasury-header">
<div class="treasury-title">
<span class="coin-icon">Ü</span>
übercoin Treasury
</div>
<div class="treasury-subtitle">Burned coins redistribute every Sunday</div>
</div>
<div class="countdown-section">
<div class="countdown-label">Next Distribution In</div>
<div class="countdown-value">{formatCountdown(countdown)}</div>
</div>
<div class="stat-grid">
<div class="stat-card highlight">
<div class="stat-label">Treasury Balance</div>
<div class="stat-value">{formatUbercoin($treasury.balance)}<span class="stat-suffix">UC</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Total Destroyed</div>
<div class="stat-value destroyed">{formatUbercoin($treasury.totalDestroyed)}<span class="stat-suffix">UC</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Total Users</div>
<div class="stat-value users">{$treasury.totalUsers}</div>
</div>
<div class="stat-card">
<div class="stat-label">Est. Share Per User</div>
<div class="stat-value">{formatUbercoin($treasury.estimatedShare)}<span class="stat-suffix">UC</span></div>
</div>
</div>
{#if $isAuthenticated}
<div class="your-share">
<div class="your-share-label">Your Estimated Share</div>
<div class="your-share-value">~{formatUbercoin($treasury.estimatedShare)} UC</div>
<div class="your-share-note">* Subject to your personal burn rate</div>
</div>
{/if}
<div class="info-section">
<div class="info-title">
<span>?</span> How It Works
</div>
<ul class="info-list">
<li>When users tip each other, a portion is burned (based on recipient's account age)</li>
<li>Burned coins go into the Treasury</li>
<li>Treasury grows 3.3% each day (Mon-Sat)</li>
<li>Every Sunday, the Treasury is distributed evenly to ALL users</li>
<li>Your share is taxed at your personal burn rate (destroyed forever)</li>
<li>Minimum burn rate is 1% (even for oldest accounts)</li>
</ul>
</div>
<button class="refresh-button" on:click={loadTreasury}>
Refresh Treasury Info
</button>
</div>

View file

@ -0,0 +1,302 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
/** @type {boolean} Whether the watch tab is currently active */
export let isActive = false;
let watchRooms = [];
let loading = true;
let refreshInterval = null;
$: onlineRooms = watchRooms.filter(r => r.currentVideoTitle);
$: offlineRooms = watchRooms.filter(r => !r.currentVideoTitle);
async function loadWatchRooms() {
if (!browser) return;
try {
const res = await fetch('/api/watch/rooms');
if (res.ok) {
const data = await res.json();
watchRooms = data.rooms || [];
}
} catch (e) {
console.error('Failed to load watch rooms:', e);
} finally {
loading = false;
}
}
function startRefresh() {
// Clear any existing interval first to prevent stacking
stopRefresh();
loadWatchRooms();
refreshInterval = setInterval(loadWatchRooms, 10000);
}
function stopRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
// Start/stop refresh based on active state
$: if (browser && isActive) {
startRefresh();
} else {
stopRefresh();
}
onDestroy(() => {
stopRefresh();
});
</script>
<div class="watch-tab">
<div class="watch-header">
<span class="watch-title">Watch Rooms</span>
</div>
<div class="watch-list">
{#if loading}
<div class="watch-loading">Loading watch rooms...</div>
{:else if watchRooms.length === 0}
<div class="watch-empty">No watch rooms found</div>
{:else}
{#if onlineRooms.length > 0}
<div class="watch-section-header">Playing ({onlineRooms.length})</div>
{#each onlineRooms as room (room.id)}
<a href={`/${room.name}/watch`} class="watch-item" target="_blank">
<div class="watch-preview">
{#if room.currentVideoThumbnail}
<img src={room.currentVideoThumbnail} alt={room.name} />
{:else}
<div class="preview-placeholder online">{room.name.charAt(0).toUpperCase()}</div>
{/if}
</div>
<div class="watch-info">
<span class="watch-name">{room.name}</span>
<span class="watch-video" title={room.currentVideoTitle}>
{room.currentVideoTitle}
</span>
<span class="watch-meta">
<span class="viewer-count">{room.viewerCount || 0}</span>
<span class="watch-user" style="color: {room.colorCode}">@{room.username}</span>
</span>
</div>
</a>
{/each}
{/if}
{#if offlineRooms.length > 0}
<div class="watch-section-header">Offline ({offlineRooms.length})</div>
{#each offlineRooms as room (room.id)}
<a href={`/${room.name}/watch`} class="watch-item offline" target="_blank">
<div class="watch-preview">
{#if room.offlineImageUrl}
<img src={room.offlineImageUrl} alt={room.name} />
{:else}
<div class="preview-placeholder offline">{room.name.charAt(0).toUpperCase()}</div>
{/if}
</div>
<div class="watch-info">
<span class="watch-name">{room.name}</span>
<span class="watch-video empty">No video playing</span>
<span class="watch-meta">
<span class="offline-badge">Offline</span>
<span class="watch-user" style="color: {room.colorCode}">@{room.username}</span>
</span>
</div>
</a>
{/each}
{/if}
{/if}
</div>
</div>
<style>
.watch-tab {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d1117;
}
.watch-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #30363d;
}
.watch-title {
color: #8b949e;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.watch-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.watch-list::-webkit-scrollbar {
width: 6px;
}
.watch-list::-webkit-scrollbar-track {
background: #0d1117;
}
.watch-list::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 3px;
}
.watch-loading,
.watch-empty {
color: #8b949e;
font-size: 0.8rem;
text-align: center;
padding: 2rem 1rem;
}
.watch-section-header {
font-size: 0.7rem;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.5rem 0.5rem 0.25rem;
border-bottom: 1px solid #30363d;
margin-top: 0.5rem;
}
.watch-section-header:first-child {
margin-top: 0;
}
.watch-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem;
border-radius: 4px;
transition: background 0.15s ease;
text-decoration: none;
color: inherit;
}
.watch-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.watch-item.offline {
opacity: 0.7;
}
.watch-item.offline:hover {
opacity: 1;
}
.watch-preview {
width: 48px;
height: 27px;
border-radius: 3px;
overflow: hidden;
background: #1a1a1a;
flex-shrink: 0;
}
.watch-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
font-size: 0.75rem;
font-weight: 600;
}
.preview-placeholder.online {
background: linear-gradient(135deg, #10b981, #059669);
}
.preview-placeholder.offline {
background: linear-gradient(135deg, #30363d, #484f58);
}
.watch-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.watch-name {
color: #c9d1d9;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watch-video {
color: #7ee787;
font-size: 0.7rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watch-video.empty {
color: #8b949e;
font-style: italic;
}
.watch-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.7rem;
color: #8b949e;
}
.viewer-count {
color: #f85149;
font-weight: 500;
}
.viewer-count::before {
content: '●';
margin-right: 0.2rem;
font-size: 0.5rem;
vertical-align: middle;
}
.watch-user {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.offline-badge {
color: #8b949e;
font-size: 0.65rem;
background: rgba(139, 148, 158, 0.2);
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
</style>

View file

@ -0,0 +1,700 @@
/**
* Terminal Commands Module
* Shared command handling for terminal and terminal popout
*/
import {
selectedRealms,
availableRealms,
joinRealmFilter,
leaveRealmFilter,
resetToGlobal,
fetchRealmStats,
chatUserInfo
} from '$lib/chat/chatStore';
import { get } from 'svelte/store';
/**
* Available commands with their descriptions
*/
export const COMMANDS = {
help: {
aliases: ['help'],
description: 'Show available commands',
usage: '/help'
},
realms: {
aliases: ['realms', 'list'],
description: 'List all realms with filter status',
usage: '/realms'
},
join: {
aliases: ['join'],
description: 'Add realm to chat filter (check)',
usage: '/join <realm-name-or-id>'
},
leave: {
aliases: ['leave'],
description: 'Remove realm from chat filter (uncheck)',
usage: '/leave <realm-name-or-id>'
},
global: {
aliases: ['global'],
description: 'Show all realms (reset filter)',
usage: '/global'
},
disconnect: {
aliases: ['disconnect'],
description: 'Disconnect from WebSocket',
usage: '/disconnect'
},
stickers: {
aliases: ['stickers'],
description: 'Toggle sticker images in messages',
usage: '/stickers'
},
graffiti: {
aliases: ['graffiti'],
description: 'Post your graffiti to chat',
usage: '/graffiti'
},
cls: {
aliases: ['cls'],
description: 'Clear terminal messages',
usage: '/cls'
},
// Moderation commands
kick: {
aliases: ['kick'],
description: 'Kick user (disconnect + 1 min block)',
usage: '/kick <username> [reason]',
modOnly: true
},
ban: {
aliases: ['ban'],
description: 'Ban user from current realm',
usage: '/ban <username> [reason]',
modOnly: true
},
unban: {
aliases: ['unban'],
description: 'Unban user from current realm',
usage: '/unban <username>',
modOnly: true
},
mute: {
aliases: ['mute'],
description: 'Mute user (default 5 min)',
usage: '/mute <username> [duration-seconds] [reason]',
modOnly: true
},
timeout: {
aliases: ['timeout'],
description: 'Timeout user (default 1 min)',
usage: '/timeout <username> [duration-seconds] [reason]',
modOnly: true
},
uberban: {
aliases: ['uberban'],
description: 'Site-wide fingerprint ban (admin/site-mod only)',
usage: '/uberban <username> [reason]',
adminOnly: true
},
ununberban: {
aliases: ['ununberban'],
description: 'Remove site-wide ban (admin/site-mod only)',
usage: '/ununberban <fingerprint>',
adminOnly: true
}
};
/**
* Parse a command string into command name and arguments
* @param {string} input - Raw input string starting with /
* @returns {{ command: string, args: string[] } | null}
*/
export function parseCommand(input) {
if (!input || !input.startsWith('/')) {
return null;
}
const parts = input.slice(1).split(' ');
const command = parts[0].toLowerCase();
const args = parts.slice(1).filter(a => a.length > 0);
return { command, args };
}
/**
* Execute a terminal command
* @param {string} command - Command name
* @param {string[]} args - Command arguments
* @param {Object} context - Execution context
* @param {Function} context.addSystemMessage - Add a system message to terminal
* @param {Object} context.chatWebSocket - Chat websocket instance
* @param {Object|null} context.authUser - Current authenticated user or null
* @param {string} context.userColor - User's chosen color
* @param {string|number|null} context.realmId - Currently connected realm ID
* @param {boolean} context.isConnected - Whether connected to a realm
* @param {Function} context.clearMessages - Clear terminal messages
* @param {Function} context.toggleStickers - Toggle sticker rendering
* @param {boolean} context.renderStickers - Current sticker render state
* @param {Function} context.setRealmId - Set the current realm ID
* @param {boolean} context.showHotkeyHelp - Whether to show hotkey in help
* @param {string} context.terminalHotkey - Hotkey to show in help
* @returns {Promise<void>}
*/
export async function executeCommand(command, args, context) {
const {
addSystemMessage,
chatWebSocket,
authUser,
userColor,
realmId,
isConnected,
clearMessages,
toggleStickers,
renderStickers,
setRealmId,
showHotkeyHelp = false,
terminalHotkey = '`'
} = context;
switch (command) {
case 'help':
await showHelp(addSystemMessage, showHotkeyHelp, terminalHotkey);
break;
case 'realms':
case 'list':
await listRealms(addSystemMessage);
break;
case 'join':
if (args[0]) {
await joinRealmChat(args[0], addSystemMessage, chatWebSocket, setRealmId, authUser);
} else {
addSystemMessage('Usage: /join <realm-name-or-id>');
}
break;
case 'leave':
if (args[0]) {
await leaveRealmChat(args[0], addSystemMessage);
} else {
addSystemMessage('Usage: /leave <realm-name-or-id>');
}
break;
case 'global':
resetToGlobal();
if (realmId && isConnected) {
addSystemMessage('Showing messages from all realms (sending to current realm)');
} else {
addSystemMessage('Showing all realms (use /join <realm> to send messages)');
}
break;
case 'disconnect':
chatWebSocket.disconnect();
setRealmId(null);
addSystemMessage('Disconnected from chat');
break;
case 'stickers':
toggleStickers();
if (!renderStickers) {
addSystemMessage('Sticker images enabled in terminal');
} else {
addSystemMessage('Sticker images disabled (showing as text)');
}
break;
case 'graffiti':
if (!realmId || !isConnected) {
addSystemMessage('Not connected to any realm. Use /join <realm> to connect.');
} else if (authUser?.graffitiUrl) {
chatWebSocket.sendMessage(`[graffiti]${authUser.graffitiUrl}[/graffiti]`, userColor);
} else {
addSystemMessage('You don\'t have a graffiti yet. Create one in Settings > Appearance.');
}
break;
case 'cls':
clearMessages();
break;
// Moderation commands
case 'kick':
await handleKick(args, addSystemMessage, chatWebSocket, realmId, isConnected);
break;
case 'ban':
await handleBan(args, addSystemMessage, chatWebSocket, realmId, isConnected);
break;
case 'unban':
await handleUnban(args, addSystemMessage, chatWebSocket, realmId, isConnected);
break;
case 'mute':
await handleMute(args, addSystemMessage, chatWebSocket, realmId, isConnected);
break;
case 'timeout':
await handleTimeout(args, addSystemMessage, chatWebSocket, realmId, isConnected);
break;
case 'uberban':
await handleUberban(args, addSystemMessage, chatWebSocket, realmId, isConnected);
break;
case 'ununberban':
await handleUnUberban(args, addSystemMessage, chatWebSocket, realmId, isConnected);
break;
default:
addSystemMessage(`Unknown command: ${command}. Type /help for available commands.`);
}
}
/**
* Show help text
*/
async function showHelp(addSystemMessage, showHotkeyHelp, terminalHotkey) {
addSystemMessage('=== Commands ===');
addSystemMessage('/realms, /list - List all realms with filter status');
addSystemMessage('/join <name> - Add realm to filter (show messages)');
addSystemMessage('/leave <name> - Remove realm from filter (hide messages)');
addSystemMessage('/global - Show all realms (reset filter)');
addSystemMessage('/disconnect - Disconnect from WebSocket');
addSystemMessage('/stickers - Toggle sticker images in messages');
addSystemMessage('/graffiti - Post your graffiti to chat');
addSystemMessage('/cls - Clear terminal');
addSystemMessage('/help - Show this help');
// Show moderation commands if user has permissions
const perms = getModPermissions();
if (perms.canMod) {
addSystemMessage('');
addSystemMessage('=== Moderation ===');
addSystemMessage('/kick <user> [reason] - Kick user (1 min rejoin block)');
addSystemMessage('/ban <user> [reason] - Ban from current realm');
addSystemMessage('/unban <user> - Unban from current realm');
addSystemMessage('/mute <user> [secs] [reason] - Mute user (default 5m)');
addSystemMessage('/timeout <user> [secs] [reason] - Timeout user (default 1m)');
if (perms.canUberban) {
addSystemMessage('/uberban <user> [reason] - Site-wide fingerprint ban');
addSystemMessage('/ununberban <fingerprint> - Remove site-wide ban');
}
}
if (showHotkeyHelp) {
addSystemMessage('');
addSystemMessage('=== Keys ===');
addSystemMessage(`${terminalHotkey} - Toggle terminal | Esc - Close`);
addSystemMessage('↑/↓ - Command history');
}
}
/**
* List all available realms with filter status
*/
async function listRealms(addSystemMessage) {
try {
// Fetch realm stats from chat service
await fetchRealmStats();
const realms = get(availableRealms);
const selected = get(selectedRealms);
const isGlobal = selected.size === 0;
addSystemMessage('=== Available Realms ===');
if (realms.length === 0) {
addSystemMessage('No active realms');
} else {
realms.forEach((realm) => {
const checked = isGlobal || selected.has(realm.realmId) ? '[✓]' : '[ ]';
const users = realm.participantCount || 0;
addSystemMessage(`${checked} ${realm.realmId} (${users} users)`);
});
}
addSystemMessage('');
if (isGlobal) {
addSystemMessage('Currently: Global (all realms)');
} else {
const names = Array.from(selected).join(', ');
addSystemMessage(`Filtered to: ${names}`);
}
addSystemMessage('========================');
} catch (error) {
console.error('Failed to list realms:', error);
addSystemMessage('Error fetching realms');
}
}
/**
* Join a realm chat (add to filter)
*/
async function joinRealmChat(nameOrId, addSystemMessage, chatWebSocket, setRealmId, authUser) {
try {
let targetRealmId = nameOrId;
let realmName = nameOrId;
// If it's not a number, search by name to get the ID
if (isNaN(nameOrId)) {
const response = await fetch(`/api/realms/by-name/${nameOrId}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
targetRealmId = String(data.realm.id);
realmName = data.realm.name || nameOrId;
} else {
addSystemMessage(`Realm "${nameOrId}" not found`);
return;
}
}
// Add realm to filter
joinRealmFilter(targetRealmId);
// Get token for authenticated connection
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
// Connect to the realm's WebSocket
await chatWebSocket.connect(targetRealmId, token);
// Set the realm ID so we can send messages to it
setRealmId(targetRealmId);
addSystemMessage(`Connected to ${realmName} - you can now send messages`);
} catch (error) {
console.error('Failed to join realm chat:', error);
addSystemMessage(`Error joining realm: ${nameOrId}`);
}
}
/**
* Leave a realm chat (remove from filter)
*/
async function leaveRealmChat(nameOrId, addSystemMessage) {
try {
let targetRealmId = nameOrId;
// If it's not a number, search by name to get the ID
if (isNaN(nameOrId)) {
const response = await fetch(`/api/realms/by-name/${nameOrId}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
targetRealmId = String(data.realm.id);
} else {
addSystemMessage(`Realm "${nameOrId}" not found`);
return;
}
}
// Remove realm from filter
leaveRealmFilter(targetRealmId);
// Check if now in global mode
const selected = get(selectedRealms);
if (selected.size === 0) {
addSystemMessage(`Left ${nameOrId} chat (now showing all realms)`);
} else {
addSystemMessage(`Left ${nameOrId} chat (messages now hidden)`);
}
} catch (error) {
console.error('Failed to leave realm chat:', error);
addSystemMessage(`Error leaving realm: ${nameOrId}`);
}
}
// =====================
// Moderation Commands
// =====================
/**
* Check if current user has moderation permissions
* @returns {{ canMod: boolean, canUberban: boolean, isAdmin: boolean, isSiteMod: boolean, isRealmMod: boolean, isStreamer: boolean }}
*/
function getModPermissions() {
const userInfo = get(chatUserInfo);
const isAdmin = userInfo.isAdmin || false;
const isSiteMod = userInfo.isSiteModerator || false;
const isRealmMod = userInfo.isModerator || false;
const isStreamer = userInfo.isStreamer || false;
return {
canMod: isAdmin || isSiteMod || isRealmMod || isStreamer,
canUberban: isAdmin || isSiteMod,
isAdmin,
isSiteMod,
isRealmMod,
isStreamer
};
}
/**
* Resolve a username to a userId by searching connected users
* For now, this uses the chat service's connected users list
*/
async function resolveUsername(username) {
// The chat service handles username resolution on the backend
// We just pass the username and let the backend resolve it
return username;
}
/**
* Handle /kick command
*/
async function handleKick(args, addSystemMessage, chatWebSocket, realmId, isConnected) {
const perms = getModPermissions();
if (!perms.canMod) {
addSystemMessage('Permission denied. You need moderator privileges to kick users.');
return;
}
if (!isConnected || !realmId) {
addSystemMessage('Not connected to any realm. Use /join <realm> first.');
return;
}
if (!args[0]) {
addSystemMessage('Usage: /kick <username> [reason]');
return;
}
const targetUser = args[0];
const reason = args.slice(1).join(' ') || '';
try {
chatWebSocket.kickUser(targetUser, 60, reason);
addSystemMessage(`Kicked ${targetUser}${reason ? ` (${reason})` : ''}`);
} catch (error) {
addSystemMessage(`Error kicking user: ${error.message}`);
}
}
/**
* Handle /ban command (per-realm ban)
*/
async function handleBan(args, addSystemMessage, chatWebSocket, realmId, isConnected) {
const perms = getModPermissions();
if (!perms.canMod) {
addSystemMessage('Permission denied. You need moderator privileges to ban users.');
return;
}
if (!isConnected || !realmId) {
addSystemMessage('Not connected to any realm. Use /join <realm> first.');
return;
}
if (!args[0]) {
addSystemMessage('Usage: /ban <username> [reason]');
return;
}
const targetUser = args[0];
const reason = args.slice(1).join(' ') || '';
try {
chatWebSocket.banUser(targetUser, reason);
addSystemMessage(`Banned ${targetUser} from this realm${reason ? ` (${reason})` : ''}`);
} catch (error) {
addSystemMessage(`Error banning user: ${error.message}`);
}
}
/**
* Handle /unban command
*/
async function handleUnban(args, addSystemMessage, chatWebSocket, realmId, isConnected) {
const perms = getModPermissions();
if (!perms.canMod) {
addSystemMessage('Permission denied. You need moderator privileges to unban users.');
return;
}
if (!isConnected || !realmId) {
addSystemMessage('Not connected to any realm. Use /join <realm> first.');
return;
}
if (!args[0]) {
addSystemMessage('Usage: /unban <username>');
return;
}
const targetUser = args[0];
try {
chatWebSocket.unbanUser(targetUser);
addSystemMessage(`Unbanned ${targetUser} from this realm`);
} catch (error) {
addSystemMessage(`Error unbanning user: ${error.message}`);
}
}
/**
* Handle /mute command
*/
async function handleMute(args, addSystemMessage, chatWebSocket, realmId, isConnected) {
const perms = getModPermissions();
if (!perms.canMod) {
addSystemMessage('Permission denied. You need moderator privileges to mute users.');
return;
}
if (!isConnected || !realmId) {
addSystemMessage('Not connected to any realm. Use /join <realm> first.');
return;
}
if (!args[0]) {
addSystemMessage('Usage: /mute <username> [duration-seconds] [reason]');
return;
}
const targetUser = args[0];
let duration = 300; // Default 5 minutes
let reason = '';
// Check if second arg is a number (duration)
if (args[1] && !isNaN(args[1])) {
duration = parseInt(args[1], 10);
reason = args.slice(2).join(' ');
} else {
reason = args.slice(1).join(' ');
}
try {
chatWebSocket.muteUser(targetUser, duration, reason);
const mins = Math.floor(duration / 60);
const secs = duration % 60;
const timeStr = mins > 0 ? `${mins}m${secs > 0 ? ` ${secs}s` : ''}` : `${secs}s`;
addSystemMessage(`Muted ${targetUser} for ${timeStr}${reason ? ` (${reason})` : ''}`);
} catch (error) {
addSystemMessage(`Error muting user: ${error.message}`);
}
}
/**
* Handle /timeout command
*/
async function handleTimeout(args, addSystemMessage, chatWebSocket, realmId, isConnected) {
const perms = getModPermissions();
if (!perms.canMod) {
addSystemMessage('Permission denied. You need moderator privileges to timeout users.');
return;
}
if (!isConnected || !realmId) {
addSystemMessage('Not connected to any realm. Use /join <realm> first.');
return;
}
if (!args[0]) {
addSystemMessage('Usage: /timeout <username> [duration-seconds] [reason]');
return;
}
const targetUser = args[0];
let duration = 60; // Default 1 minute
let reason = '';
// Check if second arg is a number (duration)
if (args[1] && !isNaN(args[1])) {
duration = parseInt(args[1], 10);
reason = args.slice(2).join(' ');
} else {
reason = args.slice(1).join(' ');
}
try {
chatWebSocket.timeoutUser(targetUser, duration, reason);
const mins = Math.floor(duration / 60);
const secs = duration % 60;
const timeStr = mins > 0 ? `${mins}m${secs > 0 ? ` ${secs}s` : ''}` : `${secs}s`;
addSystemMessage(`Timed out ${targetUser} for ${timeStr}${reason ? ` (${reason})` : ''}`);
} catch (error) {
addSystemMessage(`Error timing out user: ${error.message}`);
}
}
/**
* Handle /uberban command (site-wide fingerprint ban)
*/
async function handleUberban(args, addSystemMessage, chatWebSocket, realmId, isConnected) {
const perms = getModPermissions();
if (!perms.canUberban) {
addSystemMessage('Permission denied. Only admins and site moderators can use uberban.');
return;
}
if (!isConnected || !realmId) {
addSystemMessage('Not connected to any realm. Use /join <realm> first.');
return;
}
if (!args[0]) {
addSystemMessage('Usage: /uberban <username> [reason]');
addSystemMessage('Warning: This is a site-wide fingerprint ban that affects all realms.');
return;
}
const targetUser = args[0];
const reason = args.slice(1).join(' ') || '';
try {
// Note: For uberban, we pass the userId; the backend will resolve the fingerprint
chatWebSocket.uberbanUser(targetUser, '', reason);
addSystemMessage(`Site-wide banned ${targetUser}${reason ? ` (${reason})` : ''}`);
addSystemMessage('User is now banned from all realms by fingerprint.');
} catch (error) {
addSystemMessage(`Error uberbanning user: ${error.message}`);
}
}
/**
* Handle /ununberban command (remove site-wide ban)
*/
async function handleUnUberban(args, addSystemMessage, chatWebSocket, realmId, isConnected) {
const perms = getModPermissions();
if (!perms.canUberban) {
addSystemMessage('Permission denied. Only admins and site moderators can remove uberbans.');
return;
}
if (!isConnected || !realmId) {
addSystemMessage('Not connected to any realm. Use /join <realm> first.');
return;
}
if (!args[0]) {
addSystemMessage('Usage: /ununberban <fingerprint>');
addSystemMessage('Note: Use the fingerprint hash, not the username.');
return;
}
const fingerprint = args[0];
try {
chatWebSocket.unUberbanUser(fingerprint);
addSystemMessage(`Removed site-wide ban for fingerprint: ${fingerprint.substring(0, 16)}...`);
} catch (error) {
addSystemMessage(`Error removing uberban: ${error.message}`);
}
}

View file

@ -0,0 +1,991 @@
/* Terminal Shared Styles
* This file contains CSS shared between ChatTerminal overlay and terminal popout page
*/
/* ============================================
HEADER & TAB NAVIGATION
============================================ */
.terminal-header {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background: #161b22;
border-bottom: 1px solid #30363d;
gap: 0.75rem;
}
.tab-bar {
display: flex;
gap: 0.25rem;
}
.tab-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem 0.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid transparent;
border-radius: 4px;
color: #8b949e;
font-size: 0.875rem;
font-family: 'Consolas', 'Monaco', monospace;
cursor: pointer;
transition: all 0.15s ease;
}
.tab-button:hover {
color: #c9d1d9;
background: rgba(255, 255, 255, 0.1);
}
.tab-button.active {
color: #0f0;
background: rgba(0, 255, 0, 0.1);
border-color: rgba(0, 255, 0, 0.25);
}
/* Audio tab special color */
.tab-button.audio.active {
color: #ec4899;
background: rgba(236, 72, 153, 0.15);
border-color: rgba(236, 72, 153, 0.3);
}
.header-spacer {
flex: 1;
}
.status {
display: flex;
align-items: center;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f85149;
}
.status-dot.connected {
background: #0f0;
}
.terminal-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.control-button {
background: none;
border: none;
color: #8b949e;
cursor: pointer;
padding: 0.25rem;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.control-button:hover {
background: #30363d;
color: #c9d1d9;
}
.close-button {
background: none;
border: none;
color: #8b949e;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.close-button:hover {
background: #30363d;
color: #c9d1d9;
}
/* ============================================
TAB CONTENT CONTAINER
============================================ */
.tab-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
opacity: 0.9;
}
/* ============================================
TERMINAL MESSAGES
============================================ */
.terminal-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
color: #c9d1d9;
background: #0d1117;
}
.terminal-messages::-webkit-scrollbar {
width: 8px;
}
.terminal-messages::-webkit-scrollbar-track {
background: #0d1117;
}
.terminal-messages::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
.terminal-messages::-webkit-scrollbar-thumb:hover {
background: #484f58;
}
/* ============================================
TERMINAL INPUT
============================================ */
.terminal-input-line {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.125rem 0;
margin-top: 0.25rem;
}
.prompt {
color: #0f0;
font-weight: 600;
white-space: nowrap;
font-size: 0.8rem;
}
.terminal-input {
flex: 1;
background: transparent;
border: none;
color: #c9d1d9;
font-family: inherit;
font-size: 0.8rem;
outline: none;
caret-color: #0f0;
}
.terminal-input::placeholder {
color: #484f58;
}
.terminal-input:disabled {
opacity: 0.5;
}
/* ============================================
SYSTEM MESSAGES
============================================ */
.system-message {
padding: 0.125rem 0;
color: #8b949e;
font-size: 0.8rem;
line-height: 1.3;
border-left: none;
padding-left: 0;
margin: 0;
}
.system-prefix {
color: #6e7681;
font-size: 0.75rem;
margin-right: 0.5rem;
}
.system-text {
color: #c9d1d9;
}
/* ============================================
CHAT MESSAGE OVERRIDES (compact terminal mode)
============================================ */
.terminal-messages :global(.chat-message) {
padding: 0.125rem 0;
margin: 0;
background: transparent;
border-radius: 0;
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.terminal-messages :global(.chat-message:hover) {
background: transparent;
}
.terminal-messages :global(.chat-message .message-header) {
margin-bottom: 0;
gap: 0.35rem;
font-size: 0.8rem;
flex-shrink: 0;
}
.terminal-messages :global(.chat-message .message-content) {
font-size: 0.8rem;
line-height: 1.3;
margin-left: 0;
flex: 1;
min-width: 0;
}
.terminal-messages :global(.chat-message .message-content p) {
margin: 0;
}
.terminal-messages :global(.chat-message .user-avatar),
.terminal-messages :global(.chat-message .user-avatar-placeholder) {
width: 16px;
height: 16px;
font-size: 0.5rem;
}
.terminal-messages :global(.chat-message .timestamp) {
font-size: 0.7rem;
}
.terminal-messages :global(.chat-message .badge) {
font-size: 0.55rem;
padding: 0.05rem 0.25rem;
}
/* Terminal mode: scale graffiti down to match text height */
.terminal-messages :global(.graffiti-img) {
width: auto !important;
height: 16px !important;
max-width: 100px !important;
max-height: 16px !important;
}
/* ============================================
STREAMS TAB
============================================ */
.streams-tab {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d1117;
}
.streams-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #30363d;
}
.streams-title {
color: #8b949e;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tile-toggle {
padding: 0.25rem 0.5rem;
background: rgba(0, 255, 0, 0.1);
border: 1px solid rgba(0, 255, 0, 0.25);
border-radius: 4px;
color: #0f0;
font-size: 0.7rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.tile-toggle:hover {
background: rgba(0, 255, 0, 0.2);
}
.streams-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.streams-list::-webkit-scrollbar {
width: 6px;
}
.streams-list::-webkit-scrollbar-track {
background: #0d1117;
}
.streams-list::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 3px;
}
.streams-loading,
.streams-empty {
color: #8b949e;
font-size: 0.8rem;
text-align: center;
padding: 2rem 1rem;
}
.streams-section-header {
font-size: 0.7rem;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 0.25rem 0.25rem;
border-bottom: 1px solid #21262d;
margin-bottom: 0.25rem;
}
.streams-section-header:not(:first-child) {
margin-top: 0.75rem;
}
.stream-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem;
border-radius: 4px;
transition: background 0.15s ease;
}
.stream-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.stream-item.offline {
opacity: 0.7;
}
.stream-item.offline:hover {
opacity: 1;
}
.stream-link {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
text-decoration: none;
color: inherit;
min-width: 0;
}
.stream-preview {
width: 48px;
height: 27px;
border-radius: 3px;
overflow: hidden;
background: #1a1a1a;
flex-shrink: 0;
}
.stream-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #561d5e, #8b3a92);
color: white;
font-size: 0.75rem;
font-weight: 600;
}
.preview-placeholder.live {
background: linear-gradient(135deg, #da3633, #f85149);
}
.preview-placeholder.offline {
background: linear-gradient(135deg, #30363d, #484f58);
}
.stream-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.stream-name {
color: #c9d1d9;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stream-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.7rem;
color: #8b949e;
}
.viewer-count {
color: #f85149;
font-weight: 500;
}
.viewer-count::before {
content: '';
margin-right: 0.2rem;
font-size: 0.5rem;
vertical-align: middle;
}
.stream-user {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.offline-badge {
color: #8b949e;
font-size: 0.65rem;
background: rgba(139, 148, 158, 0.2);
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.tile-btn {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #30363d;
background: rgba(255, 255, 255, 0.05);
color: #8b949e;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
flex-shrink: 0;
}
.tile-btn:hover {
background: rgba(0, 255, 0, 0.15);
border-color: rgba(0, 255, 0, 0.3);
color: #0f0;
}
.tile-btn.active {
background: rgba(0, 255, 0, 0.2);
border-color: #0f0;
color: #0f0;
}
/* ============================================
AUDIO TAB
============================================ */
.audio-tab {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d1117;
}
/* Player section (foobar/winamp style) */
.player-section {
padding: 0.75rem;
background: linear-gradient(180deg, rgba(236, 72, 153, 0.15), rgba(236, 72, 153, 0.05));
border-bottom: 1px solid rgba(236, 72, 153, 0.2);
}
.player-track-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.player-thumb {
width: 48px;
height: 48px;
border-radius: 4px;
overflow: hidden;
background: #222;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.player-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.player-thumb .placeholder {
font-size: 1.5rem;
}
.player-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.player-title {
font-size: 0.85rem;
color: #c9d1d9;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-artist {
font-size: 0.7rem;
color: #8b949e;
}
/* Progress bar */
.player-progress {
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
cursor: pointer;
margin-bottom: 0.25rem;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #ec4899, #f472b6);
border-radius: 3px;
transition: width 0.1s linear;
}
.player-times {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
color: #8b949e;
font-family: monospace;
margin-bottom: 0.5rem;
}
/* Player controls */
.player-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.ctrl-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.1);
color: #c9d1d9;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.ctrl-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.ctrl-btn.active {
background: rgba(236, 72, 153, 0.3);
color: #ec4899;
}
.ctrl-btn.play {
width: 36px;
height: 36px;
background: #ec4899;
font-size: 0.9rem;
}
.ctrl-btn.play:hover {
background: #f472b6;
}
/* Volume control */
.player-volume {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.vol-btn {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: #8b949e;
font-size: 0.8rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.vol-btn:hover {
color: #c9d1d9;
}
.volume-slider {
width: 80px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ec4899;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ec4899;
cursor: pointer;
border: none;
}
/* Empty player state */
.player-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
color: #8b949e;
font-size: 0.85rem;
}
.player-hint {
font-size: 0.7rem;
color: #6e7681;
margin-top: 0.25rem;
}
/* Queue section */
.queue-section {
border-bottom: 1px solid #30363d;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.2);
font-size: 0.7rem;
color: #8b949e;
}
.clear-btn {
padding: 0.15rem 0.4rem;
background: rgba(248, 81, 73, 0.2);
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: 3px;
color: #f85149;
font-size: 0.65rem;
font-family: inherit;
cursor: pointer;
}
.clear-btn:hover {
background: rgba(248, 81, 73, 0.3);
}
.queue-list {
max-height: 120px;
overflow-y: auto;
}
.queue-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
font-size: 0.75rem;
color: #8b949e;
cursor: pointer;
transition: background 0.15s ease;
}
.queue-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.queue-item.active {
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
}
.queue-index {
width: 20px;
text-align: center;
font-size: 0.65rem;
}
.queue-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #c9d1d9;
}
.queue-item.active .queue-title {
color: #ec4899;
}
.queue-duration {
font-size: 0.65rem;
font-family: monospace;
}
.remove-btn {
width: 18px;
height: 18px;
border: none;
background: transparent;
color: #8b949e;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
opacity: 0;
transition: all 0.15s ease;
}
.queue-item:hover .remove-btn {
opacity: 1;
}
.remove-btn:hover {
background: rgba(248, 81, 73, 0.2);
color: #f85149;
}
/* Browse section */
.audio-browse-header {
font-size: 0.7rem;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #21262d;
}
.audio-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.audio-list::-webkit-scrollbar {
width: 6px;
}
.audio-list::-webkit-scrollbar-track {
background: #0d1117;
}
.audio-list::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 3px;
}
.audio-loading,
.audio-empty {
color: #8b949e;
font-size: 0.8rem;
text-align: center;
padding: 2rem 1rem;
}
.audio-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem;
border-radius: 4px;
transition: background 0.15s ease;
}
.audio-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.audio-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
overflow: hidden;
background: #1a1a1a;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.audio-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.audio-thumb .placeholder {
font-size: 1rem;
opacity: 0.5;
}
.audio-thumb .duration {
position: absolute;
bottom: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 0.5rem;
padding: 1px 3px;
border-radius: 2px;
}
.audio-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.audio-name {
color: #c9d1d9;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.audio-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.7rem;
color: #8b949e;
}
.audio-plays {
color: #ec4899;
}
.audio-user {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.audio-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.15s ease;
}
.audio-item:hover .audio-actions {
opacity: 1;
}
.audio-btn {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid #30363d;
background: rgba(255, 255, 255, 0.05);
color: #8b949e;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.audio-btn:hover {
background: rgba(236, 72, 153, 0.15);
border-color: rgba(236, 72, 153, 0.3);
color: #ec4899;
}
.audio-btn.play {
background: rgba(236, 72, 153, 0.2);
border-color: rgba(236, 72, 153, 0.3);
color: #ec4899;
}
.audio-btn.play:hover {
background: #ec4899;
color: white;
}

View file

@ -0,0 +1,221 @@
<script>
import { watchSync, isPlaying, canControl, currentVideo } from '$lib/stores/watchSync';
export let currentTime = 0;
export let duration = 0;
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function handlePlay() {
if ($canControl) {
watchSync.play();
}
}
function handlePause() {
if ($canControl) {
watchSync.pause();
}
}
function handleSkip() {
if ($canControl && confirm('Skip to the next video?')) {
watchSync.skip();
}
}
function handleSeek(event) {
if (!$canControl) return;
const progressBar = event.currentTarget;
const rect = progressBar.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const percentage = clickX / rect.width;
const seekTime = percentage * duration;
watchSync.seek(seekTime);
}
$: progress = duration > 0 ? (currentTime / duration) * 100 : 0;
</script>
<style>
.playback-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
}
.progress-bar-container {
height: 6px;
background: #333;
border-radius: 3px;
cursor: pointer;
position: relative;
}
.progress-bar-container.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.progress-bar {
height: 100%;
background: var(--primary);
border-radius: 3px;
transition: width 0.1s linear;
}
.controls-row {
display: flex;
align-items: center;
gap: 1rem;
}
.control-buttons {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-btn {
background: none;
border: none;
color: var(--white);
cursor: pointer;
padding: 0.5rem;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
.control-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.control-btn.play-pause {
width: 40px;
height: 40px;
}
.time-display {
font-size: 0.85rem;
color: var(--gray);
font-variant-numeric: tabular-nums;
}
.video-info {
flex: 1;
min-width: 0;
text-align: center;
}
.video-title {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.no-control-hint {
font-size: 0.75rem;
color: var(--gray);
text-align: center;
margin-top: 0.25rem;
}
</style>
<div class="playback-controls">
<div
class="progress-bar-container"
class:disabled={!$canControl}
on:click={handleSeek}
role="progressbar"
aria-valuenow={currentTime}
aria-valuemin="0"
aria-valuemax={duration}
>
<div class="progress-bar" style="width: {progress}%"></div>
</div>
<div class="controls-row">
<div class="control-buttons">
{#if $isPlaying}
<button
class="control-btn play-pause"
on:click={handlePause}
disabled={!$canControl}
title={$canControl ? 'Pause' : 'You cannot control playback'}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
</button>
{:else}
<button
class="control-btn play-pause"
on:click={handlePlay}
disabled={!$canControl || !$currentVideo}
title={$canControl ? 'Play' : 'You cannot control playback'}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
{/if}
<button
class="control-btn"
on:click={handleSkip}
disabled={!$canControl}
title={$canControl ? 'Skip to next' : 'You cannot control playback'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
</div>
<span class="time-display">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div class="video-info">
{#if $currentVideo}
<div class="video-title" title={$currentVideo.title}>
{$currentVideo.title}
</div>
{:else}
<div class="video-title" style="color: var(--gray)">
No video playing
</div>
{/if}
</div>
</div>
{#if !$canControl}
<div class="no-control-hint">
Only designated users can control playback
</div>
{/if}
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,368 @@
<script>
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { browser } from '$app/environment';
import { watchSync, isPlaying, canControl, isLeadIn } from '$lib/stores/watchSync';
export let videoId = null;
export let offlineImageUrl = null;
const dispatch = createEventDispatcher();
let player = null;
let playerReady = false;
let container;
let syncCheckInterval = null;
let lastSyncTime = 0;
let lastSyncCheck = 0;
let ignoreStateChange = false;
let apiReady = false;
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
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
// Load YouTube IFrame API
function loadYouTubeAPI() {
if (!browser) return;
if (window.YT && window.YT.Player) {
apiReady = true;
initPlayer();
return;
}
// Check if script is already loading
if (document.querySelector('script[src*="youtube.com/iframe_api"]')) {
window.onYouTubeIframeAPIReady = () => {
apiReady = true;
initPlayer();
};
return;
}
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
const firstScriptTag = document.getElementsByTagName('script')[0];
if (firstScriptTag && firstScriptTag.parentNode) {
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
} else {
document.head.appendChild(tag);
}
window.onYouTubeIframeAPIReady = () => {
apiReady = true;
initPlayer();
};
}
// Try to initialize when container becomes available
$: if (container && apiReady && !player) {
initPlayer();
}
function initPlayer() {
if (!container || player) return;
if (!window.YT || !window.YT.Player) return;
player = new window.YT.Player(container, {
height: '100%',
width: '100%',
videoId: videoId || '',
playerVars: {
autoplay: 0,
controls: 1,
disablekb: 0,
fs: 1,
modestbranding: 1,
rel: 0,
playsinline: 1,
origin: window.location.origin
},
events: {
onReady: onPlayerReady,
onStateChange: onPlayerStateChange,
onError: onPlayerError
}
});
}
function onPlayerReady(event) {
playerReady = true;
dispatch('ready');
// Start sync check interval
if (syncCheckInterval) clearInterval(syncCheckInterval);
syncCheckInterval = setInterval(checkAndSync, SYNC_CHECK_INTERVAL);
}
function onPlayerStateChange(event) {
const state = event.data;
// YT.PlayerState: UNSTARTED (-1), ENDED (0), PLAYING (1), PAUSED (2), BUFFERING (3), CUED (5)
// Always handle ENDED state - crucial for playlist advancement
if (state === window.YT.PlayerState.ENDED) {
dispatch('ended');
return;
}
// For other states, respect ignoreStateChange flag
if (ignoreStateChange) return;
if (state === window.YT.PlayerState.PLAYING) {
dispatch('play', { time: player.getCurrentTime() });
// If user clicked play and has control, notify server
if ($canControl) {
watchSync.play();
}
// Report duration when video starts playing (duration is now available)
// Only report if we haven't already for this playlist item
const storeState = $watchSync;
const playlistItemId = storeState.currentVideo?.id;
const storedDuration = storeState.currentVideo?.durationSeconds || 0;
if (playlistItemId &&
playlistItemId !== durationReportedForItemId &&
storedDuration === 0) {
const playerDuration = player.getDuration();
if (playerDuration > 0) {
console.log(`Reporting duration for playlist item ${playlistItemId}: ${playerDuration}s`);
watchSync.reportDuration(playlistItemId, playerDuration);
durationReportedForItemId = playlistItemId;
}
}
} else if (state === window.YT.PlayerState.PAUSED) {
dispatch('pause', { time: player.getCurrentTime() });
// If user clicked pause and has control, notify server
if ($canControl) {
watchSync.pause();
}
}
}
function onPlayerError(event) {
console.error('YouTube player error:', event.data);
dispatch('error', { code: event.data });
}
function checkAndSync(force = false) {
if (!playerReady || !player) return;
const storeState = $watchSync;
if (!storeState.currentVideo) return;
// Only sync if we have server time
if (!storeState.serverTime) return;
// Rate limit sync checks (unless forced)
const now = Date.now();
if (!force && (now - lastSyncCheck) < MIN_SYNC_INTERVAL) {
return;
}
lastSyncCheck = now;
const playerState = player.getPlayerState();
const isPlayerPlaying = playerState === window.YT.PlayerState.PLAYING;
const isBuffering = playerState === window.YT.PlayerState.BUFFERING;
const shouldBePlaying = storeState.playbackState === 'playing';
const hasVideoEnded = playerState === window.YT.PlayerState.ENDED;
// During lead-in period, let video buffer without seeking
// Server sends leadIn=true for 3 seconds after play starts
if (storeState.leadIn) {
// 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);
}
return;
}
const expectedTime = watchSync.getExpectedTime();
const currentTime = player.getCurrentTime();
const drift = Math.abs(currentTime - expectedTime);
// Check if we need to sync (tight 2-second threshold like CyTube)
if (drift > DRIFT_THRESHOLD) {
if ($canControl) {
// Controller has seeked manually - update server with their position
// Debounce to prevent spamming server while waiting for response
const now = Date.now();
if (now - lastControllerSeekTime > CONTROLLER_SEEK_DEBOUNCE) {
console.log(`Controller seek detected: drift=${drift.toFixed(2)}s, updating server to ${currentTime.toFixed(2)}s`);
watchSync.seek(currentTime);
lastControllerSeekTime = now;
}
} else {
// Non-controller - sync back to server time
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);
}
}
// Sync play/pause state
// Don't try to restart a video that has naturally ended - wait for skip/next
if (shouldBePlaying && !isPlayerPlaying && !isBuffering && !hasVideoEnded) {
ignoreStateChange = true;
player.playVideo();
setTimeout(() => { ignoreStateChange = false; }, 500);
} else if (!shouldBePlaying && isPlayerPlaying) {
ignoreStateChange = true;
player.pauseVideo();
setTimeout(() => { ignoreStateChange = false; }, 500);
}
}
// React to video changes from the store - track by playlist item ID, not just YouTube video ID
// This handles the case where the same YouTube video appears multiple times in the playlist
$: if (playerReady && $watchSync.currentVideo?.id !== currentPlaylistItemId) {
currentPlaylistItemId = $watchSync.currentVideo?.id;
durationReportedForItemId = null; // Reset so we report duration for new video
const newVideoId = $watchSync.currentVideo?.youtubeVideoId;
if (newVideoId && player) {
videoId = newVideoId;
ignoreStateChange = true;
// Reset sync timing when video changes to avoid immediate re-sync
lastSyncCheck = Date.now();
lastSyncTime = 0;
player.loadVideoById({
videoId: newVideoId,
startSeconds: $watchSync.currentTime || 0
});
setTimeout(() => { ignoreStateChange = false; }, 1000);
}
}
// React to state changes from the store
$: if (playerReady && player && $watchSync.serverTime > lastSyncTime) {
lastSyncTime = $watchSync.serverTime;
checkAndSync();
}
// Handle window event for state changes from other users
function handleStateChange(event) {
if (!playerReady || !player) return;
const { event: action, currentTime, triggeredBy } = event.detail;
ignoreStateChange = true;
if (action === 'play') {
player.playVideo();
// Force sync after state change from another user
setTimeout(() => checkAndSync(true), 1000);
} else if (action === 'pause') {
player.pauseVideo();
} else if (action === 'seek' && currentTime !== undefined) {
player.seekTo(currentTime, true);
} else if (action === 'skip' || action === 'video_changed') {
// Video change will be handled by the reactive statement above
setTimeout(() => checkAndSync(true), 1000);
}
setTimeout(() => { ignoreStateChange = false; }, 1000);
}
// Public methods for external control
export function seekTo(time) {
if (playerReady && player) {
ignoreStateChange = true;
player.seekTo(time, true);
setTimeout(() => { ignoreStateChange = false; }, 500);
}
}
export function getCurrentTime() {
return playerReady && player ? player.getCurrentTime() : 0;
}
export function getDuration() {
return playerReady && player ? player.getDuration() : 0;
}
onMount(() => {
loadYouTubeAPI();
if (browser) {
window.addEventListener('watch-state-change', handleStateChange);
}
});
onDestroy(() => {
if (syncCheckInterval) {
clearInterval(syncCheckInterval);
}
if (browser) {
window.removeEventListener('watch-state-change', handleStateChange);
}
if (player) {
player.destroy();
player = null;
}
});
</script>
<style>
.youtube-player-container {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.youtube-player-container :global(iframe) {
width: 100%;
height: 100%;
border: none;
}
.no-video {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--gray);
gap: 1rem;
}
.no-video svg {
width: 64px;
height: 64px;
opacity: 0.5;
}
.offline-image {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
<div class="youtube-player-container">
{#if videoId || $watchSync.currentVideo}
<div bind:this={container}></div>
{:else if offlineImageUrl}
<img src={offlineImageUrl} alt="No video playing" class="offline-image" />
{:else}
<div class="no-video">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"/>
</svg>
<span>No video playing</span>
<span style="font-size: 0.85rem;">Add a video to the playlist to start watching</span>
</div>
{/if}
</div>