Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
667
frontend/src/lib/components/AudioPlayer.svelte
Normal file
667
frontend/src/lib/components/AudioPlayer.svelte
Normal 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>
|
||||
919
frontend/src/lib/components/ChessGameOverlay.svelte
Normal file
919
frontend/src/lib/components/ChessGameOverlay.svelte
Normal 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>
|
||||
1180
frontend/src/lib/components/EbookReaderOverlay.svelte
Normal file
1180
frontend/src/lib/components/EbookReaderOverlay.svelte
Normal file
File diff suppressed because it is too large
Load diff
112
frontend/src/lib/components/GlobalAudioPlayer.svelte
Normal file
112
frontend/src/lib/components/GlobalAudioPlayer.svelte
Normal 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>
|
||||
871
frontend/src/lib/components/GraffitiEditor.svelte
Normal file
871
frontend/src/lib/components/GraffitiEditor.svelte
Normal 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>
|
||||
485
frontend/src/lib/components/StreamPlayer.svelte
Normal file
485
frontend/src/lib/components/StreamPlayer.svelte
Normal 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>
|
||||
535
frontend/src/lib/components/StreamTileOverlay.svelte
Normal file
535
frontend/src/lib/components/StreamTileOverlay.svelte
Normal 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>
|
||||
467
frontend/src/lib/components/UbercoinTipModal.svelte
Normal file
467
frontend/src/lib/components/UbercoinTipModal.svelte
Normal 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}
|
||||
711
frontend/src/lib/components/chat/ChatInput.svelte
Normal file
711
frontend/src/lib/components/chat/ChatInput.svelte
Normal 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>
|
||||
1026
frontend/src/lib/components/chat/ChatMessage.svelte
Normal file
1026
frontend/src/lib/components/chat/ChatMessage.svelte
Normal file
File diff suppressed because it is too large
Load diff
1392
frontend/src/lib/components/chat/ChatPanel.svelte
Normal file
1392
frontend/src/lib/components/chat/ChatPanel.svelte
Normal file
File diff suppressed because it is too large
Load diff
733
frontend/src/lib/components/chat/ChatTerminal.svelte
Normal file
733
frontend/src/lib/components/chat/ChatTerminal.svelte
Normal 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>
|
||||
685
frontend/src/lib/components/chat/ProfilePreview.svelte
Normal file
685
frontend/src/lib/components/chat/ProfilePreview.svelte
Normal 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}
|
||||
/>
|
||||
515
frontend/src/lib/components/chat/StickerBrowser.svelte
Normal file
515
frontend/src/lib/components/chat/StickerBrowser.svelte
Normal 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>
|
||||
628
frontend/src/lib/components/terminal/AudioBrowser.svelte
Normal file
628
frontend/src/lib/components/terminal/AudioBrowser.svelte
Normal 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>
|
||||
391
frontend/src/lib/components/terminal/EbookBrowser.svelte
Normal file
391
frontend/src/lib/components/terminal/EbookBrowser.svelte
Normal 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>
|
||||
495
frontend/src/lib/components/terminal/GamesBrowser.svelte
Normal file
495
frontend/src/lib/components/terminal/GamesBrowser.svelte
Normal 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>
|
||||
448
frontend/src/lib/components/terminal/StreamsBrowser.svelte
Normal file
448
frontend/src/lib/components/terminal/StreamsBrowser.svelte
Normal 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>
|
||||
383
frontend/src/lib/components/terminal/TerminalCore.svelte
Normal file
383
frontend/src/lib/components/terminal/TerminalCore.svelte
Normal 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>
|
||||
172
frontend/src/lib/components/terminal/TerminalTabBar.svelte
Normal file
172
frontend/src/lib/components/terminal/TerminalTabBar.svelte
Normal 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">
|
||||
<>
|
||||
</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>
|
||||
350
frontend/src/lib/components/terminal/TreasuryBrowser.svelte
Normal file
350
frontend/src/lib/components/terminal/TreasuryBrowser.svelte
Normal 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>
|
||||
302
frontend/src/lib/components/terminal/WatchRoomsBrowser.svelte
Normal file
302
frontend/src/lib/components/terminal/WatchRoomsBrowser.svelte
Normal 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>
|
||||
700
frontend/src/lib/components/terminal/terminalCommands.js
Normal file
700
frontend/src/lib/components/terminal/terminalCommands.js
Normal 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}`);
|
||||
}
|
||||
}
|
||||
991
frontend/src/lib/components/terminal/terminalStyles.css
Normal file
991
frontend/src/lib/components/terminal/terminalStyles.css
Normal 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;
|
||||
}
|
||||
221
frontend/src/lib/components/watch/PlaybackControls.svelte
Normal file
221
frontend/src/lib/components/watch/PlaybackControls.svelte
Normal 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>
|
||||
1041
frontend/src/lib/components/watch/WatchPlaylist.svelte
Normal file
1041
frontend/src/lib/components/watch/WatchPlaylist.svelte
Normal file
File diff suppressed because it is too large
Load diff
368
frontend/src/lib/components/watch/YouTubePlayer.svelte
Normal file
368
frontend/src/lib/components/watch/YouTubePlayer.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue