Initial commit - realms platform

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

View file

@ -1,26 +1,56 @@
<script>
import { onMount } from 'svelte';
import { auth, isAuthenticated, isAdmin, isStreamer, userColor } from '$lib/stores/auth';
import { auth, isAuthenticated, isAdmin, isModerator, isStreamer, isRestreamer, isUploader, isTexter, isStickerCreator, isWatchCreator, userColor } from '$lib/stores/auth';
import { ubercoinBalance, formatUbercoin, fetchBalance } from '$lib/stores/ubercoin';
import { siteSettings } from '$lib/stores/siteSettings';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import ChatTerminal from '$lib/components/chat/ChatTerminal.svelte';
import EbookReaderOverlay from '$lib/components/EbookReaderOverlay.svelte';
import ChessGameOverlay from '$lib/components/ChessGameOverlay.svelte';
import GlobalAudioPlayer from '$lib/components/GlobalAudioPlayer.svelte';
import '../app.css';
let showDropdown = false;
// Check if we're on a popout page (no nav/overlays needed)
$: isPopoutPage = $page.url.pathname.startsWith('/chat/popout') || $page.url.pathname.startsWith('/chat/terminal');
// Close dropdown when route changes
$: if ($page) {
showDropdown = false;
}
async function loadSiteSettings() {
try {
const response = await fetch('/api/settings/site');
if (response.ok) {
const data = await response.json();
const settings = data.settings || {};
siteSettings.set({
site_title: settings.site_title || 'Stream',
logo_path: settings.logo_path || '',
logo_display_mode: settings.logo_display_mode || 'text'
});
}
} catch (e) {
console.error('Failed to load site settings', e);
}
}
onMount(() => {
auth.init();
auth.init().then(() => {
fetchBalance();
});
loadSiteSettings();
// Close dropdown when clicking outside
const handleClickOutside = (event) => {
if (!event.target.closest('.user-menu')) {
showDropdown = false;
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
@ -30,54 +60,86 @@
}
</script>
<svelte:head>
<title>{$siteSettings.site_title}</title>
{#if $siteSettings.logo_path}
<link rel="icon" type="image/png" href={$siteSettings.logo_path} />
<link rel="apple-touch-icon" href={$siteSettings.logo_path} />
{/if}
</svelte:head>
<style>
:global(:root) {
--nav-padding-y: 0.3rem;
--nav-margin-bottom: 0.33rem;
--nav-height: 2.66rem; /* content(32px/2rem) + padding(0.6rem) + border(1px) */
}
.nav {
background: #111;
border-bottom: 1px solid var(--border);
padding: 1rem 0;
margin-bottom: 2rem;
padding: var(--nav-padding-y) 0;
margin-bottom: var(--nav-margin-bottom);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
font-size: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--white);
text-decoration: none;
flex-shrink: 0;
}
.nav-brand-logo {
max-height: 32px;
max-width: 120px;
object-fit: contain;
}
.nav-brand-text {
background: linear-gradient(135deg, #8b5cf6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-links {
display: flex;
gap: 1rem;
align-items: center;
flex-shrink: 0;
margin-left: auto;
}
.nav-link {
color: var(--white);
text-decoration: none;
transition: color 0.2s;
padding: 0.5rem 1rem;
}
.nav-link:hover {
color: var(--primary);
}
.user-menu {
position: relative;
flex-shrink: 0;
margin-left: auto;
}
.user-avatar-btn {
width: 40px;
height: 40px;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--gray);
border: 2px solid transparent;
@ -87,6 +149,7 @@
justify-content: center;
color: var(--white);
font-weight: 600;
font-size: 0.85rem;
transition: all 0.2s;
padding: 0;
overflow: hidden;
@ -98,6 +161,7 @@
}
.user-avatar-btn.has-color.with-image {
background: transparent;
border-color: var(--user-color);
border-width: 3px;
}
@ -121,80 +185,258 @@
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
background: #111;
background: #0a0a0a;
border: 1px solid var(--border);
border-radius: 8px;
min-width: 200px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
border-radius: 12px;
min-width: 220px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
z-index: 10000;
overflow: hidden;
animation: dropdownFadeIn 0.15s ease-out;
}
@keyframes dropdownFadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-header {
position: relative;
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--border);
overflow: hidden;
}
.dropdown-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
.dropdown-header-bg {
position: absolute;
inset: 0;
z-index: 0;
}
.dropdown-header-bg img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.7;
}
.dropdown-header-bg::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(10, 10, 10, 0.1) 0%, rgba(10, 10, 10, 0.6) 100%);
}
.dropdown-header-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.dropdown-ubercoin {
position: relative;
display: block;
text-align: center;
margin-top: 6px;
font-size: 0.85rem;
color: #ffd700;
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.dropdown-ubercoin .coin-icon {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
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.65rem;
font-weight: bold;
color: #000;
}
.dropdown-badges {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
margin-top: 8px;
}
.role-badge {
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.role-badge.admin {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.role-badge.moderator {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
}
.role-badge.streamer {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.role-badge.restreamer {
background: rgba(20, 184, 166, 0.2);
color: #14b8a6;
}
.role-badge.uploader {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.role-badge.texter {
background: rgba(251, 146, 60, 0.2);
color: #fb923c;
}
.role-badge.sticker-creator {
background: rgba(236, 72, 153, 0.2);
color: #ec4899;
}
.role-badge.watch-creator {
background: rgba(99, 102, 241, 0.2);
color: #6366f1;
}
.dropdown-username {
position: relative;
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--white);
text-decoration: none;
display: block;
text-align: center;
transition: filter 0.2s;
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.dropdown-username:hover {
filter: brightness(1.3);
}
.user-color-dot {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 6px currentColor;
}
.dropdown-menu {
padding: 0.5rem;
}
.dropdown-item {
position: relative;
display: flex;
align-items: center;
transition: color 0.2s;
justify-content: center;
padding: 0.6rem 0.75rem;
color: var(--gray);
text-decoration: none;
transition: all 0.15s ease;
border-radius: 8px;
margin: 2px 0;
font-size: 0.9rem;
border: none;
background: transparent;
width: 100%;
cursor: pointer;
text-align: center;
}
.dropdown-username:hover {
.dropdown-item:hover {
background: rgba(139, 92, 246, 0.15);
color: var(--white);
}
.dropdown-item:hover :global(svg) {
color: var(--primary);
}
.user-color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dropdown-role {
font-size: 0.85rem;
.dropdown-item :global(svg) {
position: absolute;
left: 0.75rem;
width: 18px;
height: 18px;
color: var(--gray);
transition: color 0.15s ease;
}
.dropdown-item {
display: block;
padding: 0.75rem 1rem;
color: var(--white);
text-decoration: none;
transition: background 0.2s;
}
.dropdown-item:hover {
background: rgba(86, 29, 94, 0.2);
}
.dropdown-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
margin: 0.5rem 0.5rem;
}
.dropdown-item.logout {
color: var(--gray);
}
.dropdown-item.logout:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--error);
}
.dropdown-item.logout:hover :global(svg) {
color: var(--error);
}
</style>
{#if !isPopoutPage}
<nav class="nav">
<div class="nav-container">
<a href="/" class="nav-brand">Stream</a>
<a href="/" class="nav-brand">
{#if $siteSettings.logo_display_mode === 'image' || $siteSettings.logo_display_mode === 'both'}
{#if $siteSettings.logo_path}
<img src={$siteSettings.logo_path} alt={$siteSettings.site_title} class="nav-brand-logo" />
{/if}
{/if}
{#if $siteSettings.logo_display_mode === 'text' || $siteSettings.logo_display_mode === 'both'}
<span class="nav-brand-text">{$siteSettings.site_title}</span>
{/if}
{#if !$siteSettings.logo_display_mode || ($siteSettings.logo_display_mode === 'image' && !$siteSettings.logo_path)}
<span class="nav-brand-text">{$siteSettings.site_title}</span>
{/if}
</a>
{#if !$auth.loading}
{#if $isAuthenticated}
<div class="user-menu">
<button
class="user-avatar-btn"
<button
class="user-avatar-btn"
class:has-color={$userColor}
class:with-image={$auth.user.avatarUrl}
style="--user-color: {$userColor}"
@ -206,47 +448,105 @@
{$auth.user.username.charAt(0).toUpperCase()}
{/if}
</button>
{#if showDropdown}
<div class="dropdown">
<div class="dropdown-header">
<a href="/profile/{$auth.user.username}" class="dropdown-username">
<span
class="user-color-dot"
style="background: {$userColor}"
></span>
{$auth.user.username}
</a>
<div class="dropdown-role">
{#if $isAdmin}
Admin
{:else if $isStreamer}
Streamer
{:else}
User
{#if $auth.user.bannerUrl}
<div class="dropdown-header-bg">
<img
src={$auth.user.bannerUrl}
alt=""
style="object-position: {$auth.user.bannerPositionX ?? 50}% {$auth.user.bannerPosition ?? 50}%;"
/>
</div>
{:else}
<div
class="dropdown-header-bg"
style="background: linear-gradient(135deg, {$userColor} 0%, {$userColor}66 100%);"
></div>
{/if}
<div class="dropdown-header-content">
<a href="/profile/{$auth.user.username}" class="dropdown-username" style="color: {$userColor};">
<span
class="user-color-dot"
style="background: {$userColor}; color: {$userColor}"
></span>
{$auth.user.username}
</a>
<div class="dropdown-ubercoin">
<span class="coin-icon">Ü</span>
<span>{formatUbercoin($ubercoinBalance)}</span>
</div>
{#if $isAdmin || $isModerator || $isStreamer || $isRestreamer || $isUploader || $isTexter || $isStickerCreator || $isWatchCreator}
<div class="dropdown-badges">
{#if $isAdmin}<span class="role-badge admin">Admin</span>{/if}
{#if $isModerator}<span class="role-badge moderator">Mod</span>{/if}
{#if $isStreamer}<span class="role-badge streamer">Streamer</span>{/if}
{#if $isRestreamer}<span class="role-badge restreamer">Restreamer</span>{/if}
{#if $isUploader}<span class="role-badge uploader">Uploader</span>{/if}
{#if $isTexter}<span class="role-badge texter">Texter</span>{/if}
{#if $isStickerCreator}<span class="role-badge sticker-creator">Stickers</span>{/if}
{#if $isWatchCreator}<span class="role-badge watch-creator">Watch</span>{/if}
</div>
{/if}
</div>
</div>
<a href="/settings" class="dropdown-item">
Settings
</a>
{#if $isStreamer}
<a href="/my-realms" class="dropdown-item">
My Realms
<div class="dropdown-menu">
<a href="/settings" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
Settings
</a>
{/if}
{#if $isAdmin}
<a href="/admin" class="dropdown-item">
Admin
<a href="/forums" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Forums
</a>
{/if}
<div class="dropdown-divider"></div>
<button class="dropdown-item logout" on:click={() => auth.logout()}>
Logout
</button>
<a href="/games" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="6" width="20" height="12" rx="2"/>
<path d="M6 12h4M8 10v4M15 11h.01M18 13h.01"/>
</svg>
Games
</a>
<a href="/stats" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10M12 20V4M6 20v-6"/>
</svg>
Stats
</a>
{#if $isStreamer || $isUploader}
<a href="/my-realms" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polygon points="10 8 16 12 10 16 10 8" fill="currentColor"/>
</svg>
My Realms
</a>
{/if}
{#if $isAdmin}
<a href="/admin" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
Admin
</a>
{/if}
<div class="dropdown-divider"></div>
<button class="dropdown-item logout" on:click={() => auth.logout()}>
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
</svg>
Logout
</button>
</div>
</div>
{/if}
</div>
@ -259,4 +559,12 @@
</div>
</nav>
{#if browser}
<GlobalAudioPlayer />
<ChatTerminal />
<EbookReaderOverlay />
<ChessGameOverlay />
{/if}
{/if}
<slot />

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,513 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { auth, userColor } from '$lib/stores/auth';
import { siteSettings } from '$lib/stores/siteSettings';
import { watchSync, viewerCount, canControl, canAddToPlaylist, currentVideo } from '$lib/stores/watchSync';
import { chatLayout } from '$lib/stores/chatLayout';
import ChatPanel from '$lib/components/chat/ChatPanel.svelte';
import YouTubePlayer from '$lib/components/watch/YouTubePlayer.svelte';
import WatchPlaylist from '$lib/components/watch/WatchPlaylist.svelte';
import PlaybackControls from '$lib/components/watch/PlaybackControls.svelte';
let realm = null;
let loading = true;
let error = '';
let playlist = [];
let isOwner = false;
let playerComponent;
let currentTime = 0;
let duration = 0;
let playlistRefreshInterval;
let skipInProgress = false;
$: realmName = $page.params.realm;
// Re-check ownership when auth state changes (login/logout)
$: {
$auth; // Track auth store changes
if (realm) {
checkOwnership();
}
}
onMount(async () => {
await loadRealm(realmName);
if (!realm) {
error = 'Realm not found';
loading = false;
return;
}
if (realm.type !== 'watch') {
error = 'This is not a watch room';
loading = false;
return;
}
// Connect to watch sync WebSocket
const token = localStorage.getItem('token');
watchSync.connect(realm.id, token);
// Load playlist
await loadPlaylist();
// Check if user is owner (for settings access)
checkOwnership();
// Refresh playlist periodically
playlistRefreshInterval = setInterval(loadPlaylist, 10000);
loading = false;
});
onDestroy(() => {
watchSync.disconnect();
if (playlistRefreshInterval) {
clearInterval(playlistRefreshInterval);
}
});
async function loadRealm(name) {
try {
const response = await fetch(`/api/realms/by-name/${name}`);
if (response.ok) {
const data = await response.json();
realm = data.realm;
} else if (response.status === 404) {
error = 'Realm not found';
}
} catch (e) {
console.error('Failed to load realm:', e);
error = 'Failed to load realm';
}
}
async function loadPlaylist() {
if (!realm) return;
try {
const response = await fetch(`/api/watch/${realm.id}/playlist`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
playlist = data.playlist || [];
}
} catch (e) {
console.error('Failed to load playlist:', e);
}
}
function checkOwnership() {
if (!realm) return;
const user = $auth.user;
// Check if user is owner/admin (used for settings access)
isOwner = (user?.id === realm.ownerId) || user?.isAdmin;
}
function handleVideoAdded(event) {
loadPlaylist();
// Request sync to get updated current video state (video may have auto-started)
setTimeout(() => {
watchSync.requestSync();
}, 500);
}
function handleVideoRemoved(event) {
loadPlaylist();
// Request sync to get updated state (current video may have been cleared)
setTimeout(() => {
watchSync.requestSync();
}, 500);
}
function handlePlayerReady() {
// Player is ready
}
function handleVideoEnded() {
// Prevent duplicate skip calls
if (skipInProgress) return;
// When a video ends, skip to the next one
if ($canControl) {
skipInProgress = true;
watchSync.skip();
// Reset flag after a delay and refresh playlist
setTimeout(() => {
skipInProgress = false;
loadPlaylist();
}, 2000);
} else {
// Non-controllers just request sync to get the updated state
// (the server auto-advances after a short delay or owner skips)
setTimeout(() => {
watchSync.requestSync();
loadPlaylist();
}, 1000);
}
}
// Update current time from player for progress display
function updateTime() {
if (playerComponent) {
currentTime = playerComponent.getCurrentTime() || 0;
duration = playerComponent.getDuration() || 0;
}
requestAnimationFrame(updateTime);
}
$: if (browser && playerComponent) {
requestAnimationFrame(updateTime);
}
</script>
<svelte:head>
<title>{realm ? `${$siteSettings.site_title} - ${realm.name} Watch` : $siteSettings.site_title}</title>
</svelte:head>
<style>
.watch-container {
max-width: 100%;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 22%;
gap: 0.33rem;
background: var(--black);
color: var(--white);
}
@media (max-width: 1200px) {
.watch-container {
grid-template-columns: 1fr 28%;
}
}
@media (max-width: 1024px) {
.watch-container {
grid-template-columns: 1fr;
}
}
.main-section {
display: flex;
flex-direction: column;
gap: 0.33rem;
}
.player-wrapper {
background: #000;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
position: relative;
}
.room-info-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 1rem;
position: relative;
overflow: hidden;
}
.room-info-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--user-color, var(--primary));
opacity: 0.6;
}
.room-header {
padding-left: 1rem;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.header-top > * {
flex: 1;
}
.room-header h1 {
margin: 0;
font-size: 1.5rem;
color: var(--white);
text-align: center;
}
.header-top .viewer-badge {
justify-content: flex-end;
}
.viewer-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
background: rgba(86, 29, 94, 0.2);
border-radius: 20px;
font-size: 0.85rem;
color: var(--primary);
flex-shrink: 0;
}
.viewer-badge svg {
width: 16px;
height: 16px;
}
.owner-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.owner-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--gray);
overflow: hidden;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.85rem;
color: var(--white);
flex-shrink: 0;
}
.owner-avatar.has-color {
background: var(--user-color);
border-color: var(--user-color);
}
.owner-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.owner-name {
font-weight: 500;
color: var(--white);
font-size: 0.95rem;
text-decoration: none;
transition: color 0.2s;
}
.owner-name:hover {
color: var(--primary);
}
.room-description {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
color: var(--gray);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 0.33rem;
position: sticky;
top: 0;
height: calc(100vh - var(--nav-height));
max-height: calc(100vh - var(--nav-height));
overflow: hidden;
}
.playlist-section {
flex: 0 0 auto;
max-height: 240px;
overflow: hidden;
}
.chat-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.loading-container, .error-container {
text-align: center;
padding: 4rem 2rem;
color: var(--gray);
}
.error-container h1 {
color: var(--white);
margin-bottom: 1rem;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
background: var(--primary);
color: white;
text-decoration: none;
border-radius: 8px;
transition: background 0.2s;
}
.btn:hover {
background: var(--primary-hover);
}
/* Chat position variants */
.watch-container.chat-left {
grid-template-columns: 22% 1fr;
}
.watch-container.chat-left .main-section {
order: 2;
}
.watch-container.chat-left .sidebar {
order: 1;
}
@media (max-width: 1024px) {
.watch-container.chat-left {
grid-template-columns: 1fr;
}
.watch-container.chat-left .main-section,
.watch-container.chat-left .sidebar {
order: unset;
}
.sidebar {
height: auto;
max-height: none;
}
.playlist-section {
flex: none;
max-height: 260px;
}
.chat-section {
min-height: 400px;
}
}
</style>
{#if loading}
<div class="loading-container">
<p>Loading watch room...</p>
</div>
{:else if error}
<div class="error-container">
<h1>Error</h1>
<p>{error}</p>
<a href="/" class="btn" style="margin-top: 2rem;">Back to Home</a>
</div>
{:else if realm}
<div
class="watch-container"
class:chat-left={$chatLayout.position === 'left'}
>
<div class="main-section">
<div class="player-wrapper" style="--user-color: {realm.colorCode || '#561D5E'}">
<YouTubePlayer
bind:this={playerComponent}
videoId={$currentVideo?.youtubeVideoId}
offlineImageUrl={realm.offlineImageUrl}
on:ready={handlePlayerReady}
on:ended={handleVideoEnded}
/>
</div>
<PlaybackControls
{currentTime}
duration={$currentVideo?.durationSeconds || duration}
/>
<div class="room-info-section" style="--user-color: {realm.colorCode || '#561D5E'}">
<div class="room-header">
<div class="header-top">
<div class="owner-info">
<div
class="owner-avatar"
class:has-color={realm.colorCode}
style="--user-color: {realm.colorCode || '#561D5E'}"
>
{#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} />
{:else}
{realm.username?.charAt(0).toUpperCase() || '?'}
{/if}
</div>
<a href="/profile/{realm.username}" class="owner-name">{realm.username}</a>
</div>
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
<div class="viewer-badge">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
{$viewerCount} watching
</div>
</div>
{#if realm.description}
<div class="room-description">
{realm.description}
</div>
{/if}
</div>
</div>
</div>
<div class="sidebar">
<div class="playlist-section">
<WatchPlaylist
realmId={realm.id}
{playlist}
userCanAdd={$canAddToPlaylist}
{isOwner}
playlistControlMode={realm.playlistControlMode || 'owner'}
playlistWhitelist={realm.playlistWhitelist || '[]'}
on:videoAdded={handleVideoAdded}
on:videoRemoved={handleVideoRemoved}
on:settingsChanged={checkOwnership}
/>
</div>
<div class="chat-section">
<ChatPanel
realmId={realm.name}
userColor={$userColor}
chatEnabled={realm.chatEnabled !== false}
chatGuestsAllowed={realm.chatGuestsAllowed !== false}
hideTheaterMode={true}
/>
</div>
</div>
</div>
{/if}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,440 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { siteSettings } from '$lib/stores/siteSettings';
import { audioPlaylist } from '$lib/stores/audioPlaylist';
let audioFiles = [];
let loading = true;
let page = 1;
let hasMore = true;
// Group audio by realm
$: groupedAudio = audioFiles.reduce((acc, audio) => {
const realmKey = audio.realmId || 'unknown';
if (!acc[realmKey]) {
acc[realmKey] = {
realmId: audio.realmId,
realmName: audio.realmName || 'Unknown Realm',
audio: []
};
}
acc[realmKey].audio.push(audio);
return acc;
}, {});
$: realmGroups = Object.values(groupedAudio);
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 formatBitrate(bps) {
if (!bps) return '';
const kbps = Math.round(bps / 1000);
if (kbps >= 1000) return (kbps / 1000).toFixed(1) + ' Mbps';
return kbps + ' kbps';
}
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) + ' min ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
return date.toLocaleDateString();
}
function isInPlaylist(audioId) {
return $audioPlaylist.queue.some(t => t.id === audioId);
}
function togglePlaylist(audio, realmName) {
if (isInPlaylist(audio.id)) {
audioPlaylist.removeTrack(audio.id);
} else {
audioPlaylist.addTrack({
id: audio.id,
title: audio.title,
username: audio.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: realmName
});
}
}
function playNow(audio, realmName) {
audioPlaylist.playTrack({
id: audio.id,
title: audio.title,
username: audio.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: realmName
});
}
async function loadAudio(append = false) {
if (!browser) return;
try {
const res = await fetch(`/api/audio?page=${page}&limit=20`);
if (res.ok) {
const data = await res.json();
const fetchedAudio = data.audio || [];
if (append) {
audioFiles = [...audioFiles, ...fetchedAudio];
} else {
audioFiles = fetchedAudio;
}
hasMore = fetchedAudio.length === 20;
}
} catch (e) {
console.error('Failed to load audio:', e);
} finally {
loading = false;
}
}
function loadMore() {
page++;
loadAudio(true);
}
onMount(() => {
loadAudio();
});
</script>
<svelte:head>
<title>{$siteSettings.site_title} - Audio</title>
</svelte:head>
<style>
.hero {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
}
.hero h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #ec4899, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.1rem;
color: var(--gray);
}
/* Realm group styles */
.realm-group {
margin-bottom: 3rem;
}
.realm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.realm-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.realm-title h2 {
margin: 0;
font-size: 1.3rem;
}
.realm-badge {
background: rgba(236, 72, 153, 0.2);
color: #ec4899;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.view-realm-link {
color: #ec4899;
text-decoration: none;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.3rem;
transition: opacity 0.2s;
}
.view-realm-link:hover {
opacity: 0.8;
text-decoration: underline;
}
/* Audio list styles */
.audio-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.audio-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.2s;
}
.audio-item:hover {
border-color: #ec4899;
background: rgba(236, 72, 153, 0.05);
}
.audio-number {
width: 24px;
text-align: center;
color: var(--gray);
font-size: 0.85rem;
flex-shrink: 0;
}
.audio-thumbnail {
width: 40px;
height: 40px;
border-radius: 4px;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.audio-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.audio-thumbnail .placeholder {
font-size: 1.2rem;
opacity: 0.5;
}
.audio-info {
flex: 1;
min-width: 0;
}
.audio-title {
font-size: 0.95rem;
font-weight: 500;
color: var(--white);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.audio-meta {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--gray);
font-size: 0.75rem;
margin-top: 0.15rem;
}
.audio-bitrate {
color: #ec4899;
}
.audio-duration {
font-family: monospace;
color: var(--gray);
font-size: 0.85rem;
min-width: 45px;
text-align: right;
flex-shrink: 0;
}
.audio-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.action-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
color: var(--gray);
font-size: 0.8rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(236, 72, 153, 0.2);
border-color: #ec4899;
color: #ec4899;
}
.action-btn.play {
background: rgba(236, 72, 153, 0.2);
border-color: rgba(236, 72, 153, 0.4);
color: #ec4899;
}
.action-btn.play:hover {
background: #ec4899;
color: white;
}
.action-btn.added {
background: rgba(126, 231, 135, 0.2);
border-color: #7ee787;
color: #7ee787;
}
.no-audio {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-audio-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.load-more {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.load-more-btn {
padding: 0.75rem 2rem;
background: #ec4899;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.load-more-btn:hover {
background: #db2777;
}
</style>
<div class="container">
<div class="hero">
<h1>Audio Realms</h1>
<p>Browse uploaded audio from our community</p>
</div>
{#if loading}
<div style="text-align: center; padding: 2rem;">
<p>Loading audio...</p>
</div>
{:else if audioFiles.length === 0}
<div class="no-audio">
<div class="no-audio-icon">🎵</div>
<h2>No audio yet</h2>
<p>Be the first to upload audio!</p>
</div>
{:else}
{#each realmGroups as group}
<div class="realm-group">
<div class="realm-header">
<div class="realm-title">
<h2>{group.realmName}</h2>
<span class="realm-badge">{group.audio.length} tracks</span>
</div>
<a href={`/audio/${encodeURIComponent(group.realmName)}`} class="view-realm-link">
View all
</a>
</div>
<div class="audio-list">
{#each group.audio.slice(0, 5) as audio, index}
<div class="audio-item">
<span class="audio-number">{index + 1}</span>
<div class="audio-thumbnail">
{#if audio.thumbnailPath}
<img src={audio.thumbnailPath} alt={audio.title} />
{:else}
<span class="placeholder">🎵</span>
{/if}
</div>
<div class="audio-info">
<div class="audio-title">{audio.title}</div>
<div class="audio-meta">
<span>{audio.username}</span>
{#if audio.bitrate}
<span></span>
<span class="audio-bitrate">{formatBitrate(audio.bitrate)}</span>
{/if}
<span></span>
<span>{timeAgo(audio.createdAt)}</span>
</div>
</div>
<span class="audio-duration">{formatDuration(audio.durationSeconds)}</span>
<div class="audio-actions">
<button
class="action-btn play"
on:click={() => playNow(audio, group.realmName)}
title="Play now"
>
</button>
<button
class="action-btn"
class:added={isInPlaylist(audio.id)}
on:click={() => togglePlaylist(audio, group.realmName)}
title={isInPlaylist(audio.id) ? 'Remove from playlist' : 'Add to playlist'}
>
{isInPlaylist(audio.id) ? '✓' : '+'}
</button>
</div>
</div>
{/each}
</div>
</div>
{/each}
{#if hasMore}
<div class="load-more">
<button class="load-more-btn" on:click={loadMore}>Load More</button>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,492 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { audioPlaylist } from '$lib/stores/audioPlaylist';
let realm = null;
let audioFiles = [];
let loading = true;
let error = null;
$: realmName = $page.params.name;
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 formatBitrate(bps) {
if (!bps) return '';
const kbps = Math.round(bps / 1000);
if (kbps >= 1000) return (kbps / 1000).toFixed(1) + ' Mbps';
return kbps + ' kbps';
}
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) + ' min ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
return date.toLocaleDateString();
}
function isInPlaylist(audioId) {
return $audioPlaylist.queue.some(t => t.id === audioId);
}
function togglePlaylist(audio) {
if (isInPlaylist(audio.id)) {
audioPlaylist.removeTrack(audio.id);
} else {
audioPlaylist.addTrack({
id: audio.id,
title: audio.title,
username: audio.username || realm?.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: realm?.name
});
}
}
function addToPlaylist(audio) {
audioPlaylist.addTrack({
id: audio.id,
title: audio.title,
username: audio.username || realm?.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: realm?.name
});
}
function playNow(audio) {
audioPlaylist.playTrack({
id: audio.id,
title: audio.title,
username: audio.username || realm?.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: realm?.name
});
}
function addAllToPlaylist() {
audioFiles.forEach(audio => {
if (!isInPlaylist(audio.id)) {
addToPlaylist(audio);
}
});
}
async function loadRealmAudio() {
if (!browser || !realmName) return;
try {
const res = await fetch(`/api/audio/realm/name/${encodeURIComponent(realmName)}`);
if (res.ok) {
const data = await res.json();
realm = data.realm || null;
audioFiles = data.audio || [];
} else if (res.status === 404) {
error = 'Realm not found';
} else {
error = 'Failed to load realm';
}
} catch (e) {
console.error('Failed to load realm audio:', e);
error = 'Failed to load realm';
} finally {
loading = false;
}
}
let prevRealmName = null;
onMount(() => {
prevRealmName = realmName;
loadRealmAudio();
});
// Re-load only when realmName actually changes (not on initial mount)
$: if (browser && realmName && prevRealmName !== null && prevRealmName !== realmName) {
prevRealmName = realmName;
loading = true;
error = null;
loadRealmAudio();
}
</script>
<style>
.realm-header {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border);
}
.realm-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #ec4899, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.realm-description {
font-size: 1.1rem;
color: var(--gray);
margin-bottom: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.realm-meta {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
color: var(--gray);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.realm-meta-item {
display: flex;
align-items: center;
gap: 0.4rem;
}
.owner-link {
color: #ec4899;
text-decoration: none;
}
.owner-link:hover {
text-decoration: underline;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #ec4899;
text-decoration: none;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
.audio-count-badge {
background: rgba(236, 72, 153, 0.2);
color: #ec4899;
padding: 0.3rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
}
.realm-actions {
display: flex;
justify-content: center;
gap: 1rem;
}
.add-all-btn {
padding: 0.6rem 1.5rem;
background: rgba(236, 72, 153, 0.2);
border: 1px solid rgba(236, 72, 153, 0.4);
border-radius: 8px;
color: #ec4899;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.add-all-btn:hover {
background: rgba(236, 72, 153, 0.3);
transform: translateY(-2px);
}
.audio-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.audio-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.2s;
}
.audio-item:hover {
border-color: #ec4899;
background: rgba(236, 72, 153, 0.05);
}
.audio-number {
width: 30px;
text-align: center;
color: var(--gray);
font-size: 0.9rem;
flex-shrink: 0;
}
.audio-thumbnail {
width: 48px;
height: 48px;
border-radius: 4px;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.audio-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.audio-thumbnail .placeholder {
font-size: 1.5rem;
opacity: 0.5;
}
.audio-info {
flex: 1;
min-width: 0;
}
.audio-title {
font-size: 1rem;
font-weight: 500;
color: var(--white);
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.audio-meta {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--gray);
font-size: 0.8rem;
}
.audio-bitrate {
color: #ec4899;
}
.audio-duration {
font-family: monospace;
color: var(--gray);
font-size: 0.9rem;
min-width: 50px;
text-align: right;
}
.audio-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
color: var(--gray);
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(236, 72, 153, 0.2);
border-color: #ec4899;
color: #ec4899;
}
.action-btn.play {
background: rgba(236, 72, 153, 0.2);
border-color: rgba(236, 72, 153, 0.4);
color: #ec4899;
}
.action-btn.play:hover {
background: #ec4899;
color: white;
}
.action-btn.added {
background: rgba(126, 231, 135, 0.2);
border-color: #7ee787;
color: #7ee787;
}
.no-audio {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-audio-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.error-message {
text-align: center;
padding: 4rem 0;
color: #ef4444;
}
.loading {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.terminal-hint {
text-align: center;
margin-top: 2rem;
padding: 1rem;
background: rgba(236, 72, 153, 0.1);
border: 1px solid rgba(236, 72, 153, 0.2);
border-radius: 8px;
color: var(--gray);
font-size: 0.85rem;
}
.terminal-hint strong {
color: #ec4899;
}
</style>
<div class="container">
<a href="/audio" class="back-link">
<span>&#8592;</span> Back to all audio
</a>
{#if loading}
<div class="loading">
<p>Loading realm...</p>
</div>
{:else if error}
<div class="error-message">
<h2>{error}</h2>
<p>The audio realm you're looking for doesn't exist or has been removed.</p>
</div>
{:else if realm}
<div class="realm-header">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}
<div class="realm-meta">
<div class="realm-meta-item">
<span>by</span>
<a href={`/profile/${realm.username}`} class="owner-link">@{realm.username}</a>
</div>
<div class="audio-count-badge">
{audioFiles.length} {audioFiles.length === 1 ? 'track' : 'tracks'}
</div>
</div>
{#if audioFiles.length > 0}
<div class="realm-actions">
<button class="add-all-btn" on:click={addAllToPlaylist}>
<span>+</span> Add All to Playlist
</button>
</div>
{/if}
</div>
{#if audioFiles.length === 0}
<div class="no-audio">
<div class="no-audio-icon">🎵</div>
<h2>No audio yet</h2>
<p>This realm doesn't have any audio files yet.</p>
</div>
{:else}
<div class="audio-list">
{#each audioFiles as audio, index}
<div class="audio-item">
<span class="audio-number">{index + 1}</span>
<div class="audio-thumbnail">
{#if audio.thumbnailPath}
<img src={audio.thumbnailPath} alt={audio.title} />
{:else}
<span class="placeholder">🎵</span>
{/if}
</div>
<div class="audio-info">
<div class="audio-title">{audio.title}</div>
<div class="audio-meta">
{#if audio.bitrate}
<span class="audio-bitrate">{formatBitrate(audio.bitrate)}</span>
<span></span>
{/if}
<span>{timeAgo(audio.createdAt)}</span>
</div>
</div>
<span class="audio-duration">{formatDuration(audio.durationSeconds)}</span>
<div class="audio-actions">
<button
class="action-btn play"
on:click={() => playNow(audio)}
title="Play now"
>
</button>
<button
class="action-btn"
class:added={$audioPlaylist.queue.some(t => t.id === audio.id)}
on:click={() => togglePlaylist(audio)}
title={$audioPlaylist.queue.some(t => t.id === audio.id) ? 'Remove from playlist' : 'Add to playlist'}
>
{$audioPlaylist.queue.some(t => t.id === audio.id) ? '✓' : '+'}
</button>
</div>
</div>
{/each}
</div>
<div class="terminal-hint">
Tracks added to playlist will play in the <strong>Terminal Audio tab</strong>. Press <strong>`</strong> to open the terminal.
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,492 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { audioPlaylist } from '$lib/stores/audioPlaylist';
let realm = null;
let audioFiles = [];
let loading = true;
let error = null;
$: realmId = $page.params.id;
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 formatBitrate(bps) {
if (!bps) return '';
const kbps = Math.round(bps / 1000);
if (kbps >= 1000) return (kbps / 1000).toFixed(1) + ' Mbps';
return kbps + ' kbps';
}
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) + ' min ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
return date.toLocaleDateString();
}
function isInPlaylist(audioId) {
return $audioPlaylist.queue.some(t => t.id === audioId);
}
function togglePlaylist(audio) {
if (isInPlaylist(audio.id)) {
audioPlaylist.removeTrack(audio.id);
} else {
audioPlaylist.addTrack({
id: audio.id,
title: audio.title,
username: audio.username || realm?.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: realm?.name
});
}
}
function addToPlaylist(audio) {
audioPlaylist.addTrack({
id: audio.id,
title: audio.title,
username: audio.username || realm?.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: realm?.name
});
}
function playNow(audio) {
audioPlaylist.playTrack({
id: audio.id,
title: audio.title,
username: audio.username || realm?.username,
filePath: audio.filePath,
thumbnailPath: audio.thumbnailPath || '',
durationSeconds: audio.durationSeconds,
realmName: realm?.name
});
}
function addAllToPlaylist() {
audioFiles.forEach(audio => {
if (!isInPlaylist(audio.id)) {
addToPlaylist(audio);
}
});
}
async function loadRealmAudio() {
if (!browser || !realmId) return;
try {
const res = await fetch(`/api/audio/realm/${realmId}`);
if (res.ok) {
const data = await res.json();
realm = data.realm || null;
audioFiles = data.audio || [];
} else if (res.status === 404) {
error = 'Realm not found';
} else {
error = 'Failed to load realm';
}
} catch (e) {
console.error('Failed to load realm audio:', e);
error = 'Failed to load realm';
} finally {
loading = false;
}
}
let prevRealmId = null;
onMount(() => {
prevRealmId = realmId;
loadRealmAudio();
});
// Re-load only when realmId actually changes (not on initial mount)
$: if (browser && realmId && prevRealmId !== null && prevRealmId !== realmId) {
prevRealmId = realmId;
loading = true;
error = null;
loadRealmAudio();
}
</script>
<style>
.realm-header {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border);
}
.realm-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #ec4899, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.realm-description {
font-size: 1.1rem;
color: var(--gray);
margin-bottom: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.realm-meta {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
color: var(--gray);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.realm-meta-item {
display: flex;
align-items: center;
gap: 0.4rem;
}
.owner-link {
color: #ec4899;
text-decoration: none;
}
.owner-link:hover {
text-decoration: underline;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #ec4899;
text-decoration: none;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
.audio-count-badge {
background: rgba(236, 72, 153, 0.2);
color: #ec4899;
padding: 0.3rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
}
.realm-actions {
display: flex;
justify-content: center;
gap: 1rem;
}
.add-all-btn {
padding: 0.6rem 1.5rem;
background: rgba(236, 72, 153, 0.2);
border: 1px solid rgba(236, 72, 153, 0.4);
border-radius: 8px;
color: #ec4899;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.add-all-btn:hover {
background: rgba(236, 72, 153, 0.3);
transform: translateY(-2px);
}
.audio-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.audio-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.2s;
}
.audio-item:hover {
border-color: #ec4899;
background: rgba(236, 72, 153, 0.05);
}
.audio-number {
width: 30px;
text-align: center;
color: var(--gray);
font-size: 0.9rem;
flex-shrink: 0;
}
.audio-thumbnail {
width: 48px;
height: 48px;
border-radius: 4px;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.audio-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.audio-thumbnail .placeholder {
font-size: 1.5rem;
opacity: 0.5;
}
.audio-info {
flex: 1;
min-width: 0;
}
.audio-title {
font-size: 1rem;
font-weight: 500;
color: var(--white);
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.audio-meta {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--gray);
font-size: 0.8rem;
}
.audio-bitrate {
color: #ec4899;
}
.audio-duration {
font-family: monospace;
color: var(--gray);
font-size: 0.9rem;
min-width: 50px;
text-align: right;
}
.audio-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
color: var(--gray);
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(236, 72, 153, 0.2);
border-color: #ec4899;
color: #ec4899;
}
.action-btn.play {
background: rgba(236, 72, 153, 0.2);
border-color: rgba(236, 72, 153, 0.4);
color: #ec4899;
}
.action-btn.play:hover {
background: #ec4899;
color: white;
}
.action-btn.added {
background: rgba(126, 231, 135, 0.2);
border-color: #7ee787;
color: #7ee787;
}
.no-audio {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-audio-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.error-message {
text-align: center;
padding: 4rem 0;
color: #ef4444;
}
.loading {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.terminal-hint {
text-align: center;
margin-top: 2rem;
padding: 1rem;
background: rgba(236, 72, 153, 0.1);
border: 1px solid rgba(236, 72, 153, 0.2);
border-radius: 8px;
color: var(--gray);
font-size: 0.85rem;
}
.terminal-hint strong {
color: #ec4899;
}
</style>
<div class="container">
<a href="/audio" class="back-link">
<span>&#8592;</span> Back to all audio
</a>
{#if loading}
<div class="loading">
<p>Loading realm...</p>
</div>
{:else if error}
<div class="error-message">
<h2>{error}</h2>
<p>The audio realm you're looking for doesn't exist or has been removed.</p>
</div>
{:else if realm}
<div class="realm-header">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}
<div class="realm-meta">
<div class="realm-meta-item">
<span>by</span>
<a href={`/profile/${realm.username}`} class="owner-link">@{realm.username}</a>
</div>
<div class="audio-count-badge">
{audioFiles.length} {audioFiles.length === 1 ? 'track' : 'tracks'}
</div>
</div>
{#if audioFiles.length > 0}
<div class="realm-actions">
<button class="add-all-btn" on:click={addAllToPlaylist}>
<span>+</span> Add All to Playlist
</button>
</div>
{/if}
</div>
{#if audioFiles.length === 0}
<div class="no-audio">
<div class="no-audio-icon">🎵</div>
<h2>No audio yet</h2>
<p>This realm doesn't have any audio files yet.</p>
</div>
{:else}
<div class="audio-list">
{#each audioFiles as audio, index}
<div class="audio-item">
<span class="audio-number">{index + 1}</span>
<div class="audio-thumbnail">
{#if audio.thumbnailPath}
<img src={audio.thumbnailPath} alt={audio.title} />
{:else}
<span class="placeholder">🎵</span>
{/if}
</div>
<div class="audio-info">
<div class="audio-title">{audio.title}</div>
<div class="audio-meta">
{#if audio.bitrate}
<span class="audio-bitrate">{formatBitrate(audio.bitrate)}</span>
<span></span>
{/if}
<span>{timeAgo(audio.createdAt)}</span>
</div>
</div>
<span class="audio-duration">{formatDuration(audio.durationSeconds)}</span>
<div class="audio-actions">
<button
class="action-btn play"
on:click={() => playNow(audio)}
title="Play now"
>
</button>
<button
class="action-btn"
class:added={$audioPlaylist.queue.some(t => t.id === audio.id)}
on:click={() => togglePlaylist(audio)}
title={$audioPlaylist.queue.some(t => t.id === audio.id) ? 'Remove from playlist' : 'Add to playlist'}
>
{$audioPlaylist.queue.some(t => t.id === audio.id) ? '✓' : '+'}
</button>
</div>
</div>
{/each}
</div>
<div class="terminal-hint">
Tracks added to playlist will play in the <strong>Terminal Audio tab</strong>. Press <strong>`</strong> to open the terminal.
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,28 @@
<script>
import { onMount } from 'svelte';
import { siteSettings } from '$lib/stores/siteSettings';
import '../../../app.css';
async function loadSiteSettings() {
try {
const response = await fetch('/api/settings/site');
if (response.ok) {
const data = await response.json();
const settings = data.settings || {};
siteSettings.set({
site_title: settings.site_title || 'Stream',
logo_path: settings.logo_path || '',
logo_display_mode: settings.logo_display_mode || 'text'
});
}
} catch (e) {
console.error('Failed to load site settings', e);
}
}
onMount(() => {
loadSiteSettings();
});
</script>
<slot />

View file

@ -0,0 +1,67 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { siteSettings } from '$lib/stores/siteSettings';
import ChatPanel from '$lib/components/chat/ChatPanel.svelte';
let realmId = null;
onMount(() => {
// Parse realm ID from URL parameter
const params = new URLSearchParams(window.location.search);
realmId = params.get('realm');
});
</script>
<svelte:head>
<title>{$siteSettings.site_title} - Chat</title>
</svelte:head>
<div class="popout-container">
<ChatPanel {realmId} chatEnabled={true} chatGuestsAllowed={true} hideTheaterMode={true} />
</div>
<style>
:global(html),
:global(body) {
margin: 0;
padding: 0;
overflow: hidden;
height: 100%;
width: 100%;
min-height: 0 !important;
max-height: 100%;
}
.popout-container {
width: 100%;
height: 100%;
background: #0a0a0a;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
.popout-container :global(.chat-panel) {
flex: 1 1 0;
display: flex;
flex-direction: column;
min-height: 0;
max-height: 100%;
height: 100%;
overflow: hidden;
border-left: none;
}
.popout-container :global(.messages-container) {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
background: #000;
}
.popout-container :global(.chat-input) {
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,28 @@
<script>
import { onMount } from 'svelte';
import { siteSettings } from '$lib/stores/siteSettings';
import '../../../app.css';
async function loadSiteSettings() {
try {
const response = await fetch('/api/settings/site');
if (response.ok) {
const data = await response.json();
const settings = data.settings || {};
siteSettings.set({
site_title: settings.site_title || 'Stream',
logo_path: settings.logo_path || '',
logo_display_mode: settings.logo_display_mode || 'text'
});
}
} catch (e) {
console.error('Failed to load site settings', e);
}
}
onMount(() => {
loadSiteSettings();
});
</script>
<slot />

View file

@ -0,0 +1,564 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { connectionStatus } from '$lib/chat/chatStore';
import { auth, isAuthenticated } from '$lib/stores/auth';
import { siteSettings } from '$lib/stores/siteSettings';
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 '$lib/components/chat/StickerBrowser.svelte';
import ProfilePreview from '$lib/components/chat/ProfilePreview.svelte';
let realmId = null;
let authInitialized = false;
let activeTab = 'terminal';
let renderStickers = false;
let activeProfilePreview = null;
let terminalCore;
// Date/time state
let currentTime = new Date();
let showCalendar = false;
let calendarDate = new Date();
let timeInterval;
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' }
];
$: isConnected = $connectionStatus === 'connected';
function handleTabChange(event) {
activeTab = event.detail.tab;
}
function handleStickerSelect(stickerText) {
activeTab = 'terminal';
if (terminalCore) {
terminalCore.focusInput();
}
}
function handleShowProfile(event) {
const { username, userId, isGuest, messageId, position } = event.detail;
activeProfilePreview = { username, userId, isGuest, messageId, position };
}
function handleProfileClose() {
activeProfilePreview = null;
}
function handleRealmChange(event) {
realmId = event.detail.realmId;
document.title = realmId ? `Terminal - ${realmId}` : 'Terminal';
}
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 = [];
for (let i = 0; i < startDayOfWeek; i++) {
days.push(null);
}
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(async () => {
// Initialize auth first
await auth.init();
authInitialized = true;
const params = new URLSearchParams(window.location.search);
const urlRealmId = params.get('realm');
document.title = urlRealmId ? `Terminal - ${urlRealmId}` : 'Terminal';
if (urlRealmId) {
realmId = urlRealmId;
}
if (terminalCore) {
terminalCore.focusInput();
}
// Update time every second
timeInterval = setInterval(() => {
currentTime = new Date();
}, 1000);
});
onDestroy(() => {
if (timeInterval) clearInterval(timeInterval);
});
</script>
<svelte:window on:click={() => showCalendar = false} />
<svelte:head>
<title>{$siteSettings.site_title} - Terminal</title>
</svelte:head>
{#if !authInitialized}
<div class="terminal-popout auth-loading">
<div class="auth-message">Loading...</div>
</div>
{:else if !$isAuthenticated}
<div class="terminal-popout auth-required">
<div class="auth-message">
<h2>Login Required</h2>
<p>Please log in to access the terminal.</p>
<a href="/login" class="login-btn">Login</a>
</div>
</div>
{:else}
<div class="terminal-popout">
<div class="popout-header">
<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>
</div>
<div class="tab-content">
{#if activeTab === 'terminal'}
<TerminalCore
bind:this={terminalCore}
{realmId}
{renderStickers}
showHotkeyHelp={false}
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}
position={activeProfilePreview.position}
{realmId}
on:close={handleProfileClose}
/>
{/if}
</div>
{/if}
<style>
:global(html), :global(body) {
margin: 0;
padding: 0;
overflow: hidden;
height: 100%;
width: 100%;
min-height: 0 !important;
max-height: 100%;
}
.terminal-popout {
width: 100%;
height: 100%;
background: #111;
display: flex;
flex-direction: column;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
overflow: hidden;
}
.terminal-popout.auth-loading,
.terminal-popout.auth-required {
justify-content: center;
align-items: center;
}
.auth-message {
text-align: center;
color: #aaa;
}
.auth-message h2 {
margin: 0 0 0.5rem 0;
color: #fff;
}
.auth-message p {
margin: 0 0 1rem 0;
}
.login-btn {
display: inline-block;
padding: 0.5rem 1.5rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #fff;
text-decoration: none;
transition: all 0.15s;
}
.login-btn:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
}
.popout-header {
display: flex;
align-items: center;
padding: 0.5rem;
background: #0d0d0d;
border-bottom: 1px solid #333;
gap: 0.375rem;
flex-shrink: 0;
}
.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;
}
.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: #111;
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem;
z-index: 100;
display: flex;
gap: 0.75rem;
}
.calendar-panel {
min-width: 200px;
}
.timezone-panel {
border-left: 1px solid #333;
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: #888;
font-size: 0.7rem;
}
.timezone-time {
color: #4caf50;
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: #fff;
font-size: 0.9rem;
font-weight: 600;
}
.calendar-nav {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #aaa;
font-size: 1rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.15s;
}
.calendar-nav:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
color: #fff;
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 0.25rem;
}
.calendar-weekdays span {
text-align: center;
color: #888;
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: #ccc;
border-radius: 4px;
}
.calendar-day.empty {
color: transparent;
}
.calendar-day.today {
background: #4caf50;
color: #111;
font-weight: 600;
}
.tab-content {
flex: 1 1 0;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
opacity: 0.9;
}
/* Minimal 1px grey scrollbar */
.terminal-popout :global(*::-webkit-scrollbar) {
width: 1px;
height: 1px;
}
.terminal-popout :global(*::-webkit-scrollbar-track) {
background: transparent;
}
.terminal-popout :global(*::-webkit-scrollbar-thumb) {
background: #333;
}
.terminal-popout :global(*) {
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
</style>

View file

@ -0,0 +1,481 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { siteSettings } from '$lib/stores/siteSettings';
import { ebookReader } from '$lib/stores/ebookReader';
import { auth, isAuthenticated } from '$lib/stores/auth';
let ebooks = [];
let loading = true;
let page = 1;
let hasMore = true;
// Group ebooks by realm
$: groupedEbooks = ebooks.reduce((acc, ebook) => {
const realmKey = ebook.realmId || 'unknown';
if (!acc[realmKey]) {
acc[realmKey] = {
realmId: ebook.realmId,
realmName: ebook.realmName || 'Unknown Realm',
ebooks: []
};
}
acc[realmKey].ebooks.push(ebook);
return acc;
}, {});
$: realmGroups = Object.values(groupedEbooks);
function formatFileSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
}
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) + ' min ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
return date.toLocaleDateString();
}
async function loadEbooks(append = false) {
if (!browser) return;
try {
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;
}
// Use >= for safer pagination (handles edge case where server returns fewer)
hasMore = fetchedEbooks.length >= 20;
}
} catch (e) {
console.error('Failed to load ebooks:', e);
} finally {
loading = false;
}
}
function loadMore() {
page++;
loadEbooks(true);
}
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
});
}
async function downloadEbook(e, ebook) {
e.stopPropagation(); // Prevent card click from opening book
if (!$isAuthenticated) {
alert('Please log in to download ebooks');
return;
}
try {
const response = await fetch(`/api/ebooks/${ebook.id}/download`, {
credentials: 'include'
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Sanitize filename - remove special characters
const safeTitle = ebook.title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 100);
a.download = `${safeTitle || 'ebook'}.epub`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else if (response.status === 401) {
alert('Please log in to download ebooks');
} else {
alert('Download failed');
}
} catch (err) {
console.error('Download error:', err);
alert('Download failed');
}
}
onMount(() => {
auth.init();
loadEbooks();
});
</script>
<svelte:head>
<title>{$siteSettings.site_title} - Ebooks</title>
</svelte:head>
<style>
.hero {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
}
.hero h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #3b82f6, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.1rem;
color: var(--gray);
}
/* Realm group styles */
.realm-group {
margin-bottom: 3rem;
}
.realm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.realm-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.realm-title h2 {
margin: 0;
font-size: 1.3rem;
}
.realm-badge {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.view-realm-link {
color: #3b82f6;
text-decoration: none;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.3rem;
transition: opacity 0.2s;
}
.view-realm-link:hover {
opacity: 0.8;
text-decoration: underline;
}
.ebook-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
.ebook-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
cursor: pointer;
}
.ebook-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
border-color: #3b82f6;
}
.ebook-cover {
width: 100%;
aspect-ratio: 2/3;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.ebook-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ebook-cover .placeholder {
font-size: 3rem;
opacity: 0.3;
}
.chapter-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.format-badge {
position: absolute;
top: 0.5rem;
left: 0.5rem;
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
}
.ebook-info {
padding: 1rem;
}
.ebook-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ebook-meta {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--gray);
font-size: 0.85rem;
}
.uploader-info {
display: flex;
align-items: center;
gap: 0.4rem;
}
.uploader-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--gray);
}
.uploader-name {
cursor: pointer;
transition: filter 0.15s ease;
}
.uploader-name:hover {
filter: invert(1);
}
.chapter-count {
display: flex;
align-items: center;
gap: 0.25rem;
color: #3b82f6;
font-size: 0.8rem;
}
.upload-date {
color: #666;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.ebook-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.ebook-btn {
flex: 1;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
}
.ebook-btn:hover {
transform: translateY(-1px);
}
.read-btn {
background: #3b82f6;
color: white;
}
.read-btn:hover {
background: #2563eb;
}
.download-btn {
background: #1a1a1a;
color: white;
border: 1px solid var(--border);
}
.download-btn:hover {
background: #2a2a2a;
}
.no-ebooks {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-ebooks-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.load-more {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.load-more-btn {
padding: 0.75rem 2rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.load-more-btn:hover {
background: #2563eb;
}
</style>
<div class="container">
<div class="hero">
<h1>Ebook Realms</h1>
<p>Browse uploaded ebooks from our community</p>
</div>
{#if loading}
<div style="text-align: center; padding: 2rem;">
<p>Loading ebooks...</p>
</div>
{:else if ebooks.length === 0}
<div class="no-ebooks">
<div class="no-ebooks-icon">📖</div>
<h2>No ebooks yet</h2>
<p>Be the first to upload an ebook!</p>
</div>
{:else}
{#each realmGroups as group}
<div class="realm-group">
<div class="realm-header">
<div class="realm-title">
<h2>{group.realmName}</h2>
<span class="realm-badge">{group.ebooks.length} {group.ebooks.length === 1 ? 'book' : 'books'}</span>
</div>
<a href={`/ebooks/realm/${group.realmId}`} class="view-realm-link">
View all
</a>
</div>
<div class="ebook-grid">
{#each group.ebooks.slice(0, 6) as ebook}
<div class="ebook-card">
<div class="ebook-cover" on:click={() => openBook(ebook)}>
{#if ebook.coverPath}
<img src={ebook.coverPath} alt={ebook.title} />
{:else}
<span class="placeholder">📖</span>
{/if}
<span class="format-badge">EPUB</span>
{#if ebook.chapterCount}
<span class="chapter-badge">{ebook.chapterCount} ch</span>
{/if}
</div>
<div class="ebook-info">
<h3>{ebook.title}</h3>
<div class="ebook-meta">
<div class="uploader-info">
{#if ebook.avatarUrl}
<img src={ebook.avatarUrl} alt={ebook.username} class="uploader-avatar" />
{:else}
<div class="uploader-avatar"></div>
{/if}
<span class="uploader-name">{ebook.username}</span>
</div>
{#if ebook.chapterCount}
<div class="chapter-count">{ebook.chapterCount} chapters</div>
{/if}
</div>
<div class="upload-date">{timeAgo(ebook.createdAt)}</div>
<div class="ebook-actions">
<button class="ebook-btn read-btn" on:click={() => openBook(ebook)}>Read</button>
{#if $isAuthenticated}
<button class="ebook-btn download-btn" on:click={(e) => downloadEbook(e, ebook)}>Download</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/each}
{#if hasMore}
<div class="load-more">
<button class="load-more-btn" on:click={loadMore}>Load More</button>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,428 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { ebookReader } from '$lib/stores/ebookReader';
import { auth, isAuthenticated } from '$lib/stores/auth';
let realm = null;
let ebooks = [];
let loading = true;
let error = null;
$: realmId = $page.params.id;
function formatFileSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
}
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) + ' min ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
return date.toLocaleDateString();
}
async function loadRealmEbooks() {
if (!browser || !realmId) return;
try {
const res = await fetch(`/api/ebooks/realm/${realmId}`);
if (res.ok) {
const data = await res.json();
realm = data.realm || null;
ebooks = data.ebooks || [];
} else if (res.status === 404) {
error = 'Realm not found';
} else {
error = 'Failed to load realm';
}
} catch (e) {
console.error('Failed to load realm ebooks:', e);
error = 'Failed to load realm';
} finally {
loading = false;
}
}
onMount(() => {
auth.init();
loadRealmEbooks();
});
$: if (browser && realmId) {
loading = true;
error = null;
loadRealmEbooks();
}
function openBook(ebook) {
ebookReader.openBook({
id: ebook.id,
title: ebook.title,
filePath: ebook.filePath,
coverPath: ebook.coverPath,
chapterCount: ebook.chapterCount,
username: realm?.username || '',
realmName: realm?.name || ''
});
}
async function downloadEbook(e, ebook) {
e.stopPropagation();
if (!$isAuthenticated) {
alert('Please log in to download ebooks');
return;
}
try {
const response = await fetch(`/api/ebooks/${ebook.id}/download`, {
credentials: 'include'
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${ebook.title}.epub`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else if (response.status === 401) {
alert('Please log in to download ebooks');
} else {
alert('Download failed');
}
} catch (err) {
console.error('Download error:', err);
alert('Download failed');
}
}
</script>
<style>
.realm-header {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border);
}
.realm-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #3b82f6, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.realm-description {
font-size: 1.1rem;
color: var(--gray);
margin-bottom: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.realm-meta {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
color: var(--gray);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.realm-meta-item {
display: flex;
align-items: center;
gap: 0.4rem;
}
.owner-link {
color: #3b82f6;
text-decoration: none;
}
.owner-link:hover {
text-decoration: underline;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #3b82f6;
text-decoration: none;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
.ebook-count-badge {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
padding: 0.3rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
}
.ebook-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.5rem;
}
.ebook-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.ebook-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
border-color: #3b82f6;
}
.ebook-cover {
width: 100%;
aspect-ratio: 2/3;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.ebook-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ebook-cover .placeholder {
font-size: 3rem;
opacity: 0.3;
}
.chapter-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.format-badge {
position: absolute;
top: 0.5rem;
left: 0.5rem;
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
}
.ebook-info {
padding: 1rem;
}
.ebook-title {
font-size: 0.95rem;
font-weight: 500;
color: var(--white);
margin-bottom: 0.5rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ebook-meta {
display: flex;
flex-direction: column;
gap: 0.25rem;
color: var(--gray);
font-size: 0.8rem;
}
.chapter-count {
color: #3b82f6;
}
.file-size {
color: #666;
}
.ebook-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.read-btn {
flex: 1;
padding: 0.6rem;
background: rgba(59, 130, 246, 0.2);
border: 1px solid rgba(59, 130, 246, 0.4);
border-radius: 6px;
color: #3b82f6;
text-align: center;
text-decoration: none;
font-size: 0.85rem;
font-family: inherit;
transition: all 0.2s;
cursor: pointer;
}
.read-btn:hover {
background: #3b82f6;
color: white;
}
.download-btn {
flex: 1;
padding: 0.6rem;
background: #1a1a1a;
border: 1px solid var(--border);
border-radius: 6px;
color: white;
text-align: center;
font-size: 0.85rem;
font-family: inherit;
transition: all 0.2s;
cursor: pointer;
}
.download-btn:hover {
background: #2a2a2a;
}
.no-ebooks {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-ebooks-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.error-message {
text-align: center;
padding: 4rem 0;
color: #ef4444;
}
.loading {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
</style>
<div class="container">
<a href="/ebooks" class="back-link">
<span>&#8592;</span> Back to all ebooks
</a>
{#if loading}
<div class="loading">
<p>Loading realm...</p>
</div>
{:else if error}
<div class="error-message">
<h2>{error}</h2>
<p>The ebook realm you're looking for doesn't exist or has been removed.</p>
</div>
{:else if realm}
<div class="realm-header">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}
<div class="realm-meta">
<div class="realm-meta-item">
<span>by</span>
<a href={`/profile/${realm.username}`} class="owner-link">@{realm.username}</a>
</div>
<div class="ebook-count-badge">
{ebooks.length} {ebooks.length === 1 ? 'book' : 'books'}
</div>
</div>
</div>
{#if ebooks.length === 0}
<div class="no-ebooks">
<div class="no-ebooks-icon">📖</div>
<h2>No ebooks yet</h2>
<p>This realm doesn't have any ebooks yet.</p>
</div>
{:else}
<div class="ebook-grid">
{#each ebooks as ebook}
<div class="ebook-card">
<div class="ebook-cover">
{#if ebook.coverPath}
<img src={ebook.coverPath} alt={ebook.title} />
{:else}
<span class="placeholder">📖</span>
{/if}
<span class="format-badge">EPUB</span>
{#if ebook.chapterCount}
<span class="chapter-badge">{ebook.chapterCount} ch</span>
{/if}
</div>
<div class="ebook-info">
<div class="ebook-title">{ebook.title}</div>
<div class="ebook-meta">
{#if ebook.chapterCount}
<span class="chapter-count">{ebook.chapterCount} chapters</span>
{/if}
<span class="file-size">{formatFileSize(ebook.fileSizeBytes)}</span>
<span>{timeAgo(ebook.createdAt)}</span>
</div>
<div class="ebook-actions">
<button class="read-btn" on:click={() => openBook(ebook)}>Read</button>
{#if $isAuthenticated}
<button class="download-btn" on:click={(e) => downloadEbook(e, ebook)}>Download</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,771 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { auth, isAuthenticated, isAdmin, isModerator } from '$lib/stores/auth';
import { siteSettings } from '$lib/stores/siteSettings';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { stickersMap, ensureLoaded } from '$lib/stores/stickers';
import ProfilePreview from '$lib/components/chat/ProfilePreview.svelte';
let forum = null;
let thread = null;
let posts = [];
let loading = true;
let error = '';
let isBanned = false;
// Reply state
let replyContent = '';
let submittingReply = false;
let replyError = '';
// Edit state
let editingPostId = null;
let editContent = '';
let editingThread = false;
let editThreadContent = '';
// Profile preview state
let activeProfilePreview = null;
$: isOwner = forum && $auth.user && forum.userId === $auth.user.id;
$: canModerate = isOwner || $isAdmin || $isModerator;
$: isThreadAuthor = thread && $auth.user && thread.userId === $auth.user.id;
marked.setOptions({
breaks: true,
gfm: true
});
onMount(async () => {
await ensureLoaded();
const unsubscribe = auth.subscribe(async (state) => {
if (!state.loading) {
if (!state.user) {
goto('/login');
return;
}
await loadThread();
unsubscribe();
}
});
});
async function loadThread() {
loading = true;
error = '';
const { slug, threadId } = $page.params;
try {
// Load forum details
const forumResponse = await fetch(`/api/forums/${slug}`, {
credentials: 'include'
});
if (forumResponse.ok) {
const forumData = await forumResponse.json();
forum = forumData.forum;
}
// Load thread with posts
const threadResponse = await fetch(`/api/forums/${slug}/threads/${threadId}`, {
credentials: 'include'
});
if (threadResponse.ok) {
const threadData = await threadResponse.json();
thread = threadData.thread;
} else if (threadResponse.status === 404) {
error = 'Thread not found';
loading = false;
return;
}
// Load posts
const postsResponse = await fetch(`/api/forums/${slug}/threads/${threadId}/posts`, {
credentials: 'include'
});
if (postsResponse.ok) {
const postsData = await postsResponse.json();
posts = postsData.posts || [];
} else if (postsResponse.status === 403) {
const data = await postsResponse.json();
if (data.error && data.error.includes('banned')) {
isBanned = true;
}
}
} catch (e) {
error = 'Network error';
}
loading = false;
}
function parseContent(content, stickerMap) {
if (!content) return '';
let msg = content;
// Replace stickers
msg = msg.replace(/:(\w+):/g, (match, name) => {
const key = name.toLowerCase();
if (stickerMap && stickerMap[key]) {
return `<img src="${stickerMap[key]}" alt="${name}" title="${name}" class="sticker-img" />`;
}
return match;
});
let html = marked.parse(msg);
// Handle redtext
html = html.replace(/<p>&lt;&lt;(.+?)<\/p>/gs, '<p class="redtext">&lt;&lt;$1</p>');
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'img', 'br', 'strong', 'em', 'code', 'pre', 'del', 'span'],
ALLOWED_ATTR: ['src', 'alt', 'title', 'class', 'style'],
FORBID_TAGS: ['a', 'button', 'script']
});
}
// Reactive parsed content - re-run when stickers load
$: parsedThreadContent = thread ? parseContent(thread.content, $stickersMap) : '';
$: parsedPostsContent = posts.reduce((acc, post) => {
acc[post.id] = parseContent(post.content, $stickersMap);
return acc;
}, {});
async function submitReply() {
if (!replyContent.trim()) {
replyError = 'Reply cannot be empty';
return;
}
submittingReply = true;
replyError = '';
try {
const response = await fetch(`/api/forums/${$page.params.slug}/threads/${$page.params.threadId}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ content: replyContent.trim() })
});
const data = await response.json();
if (response.ok && data.success) {
// Add username info to new post
data.post.username = $auth.user.username;
data.post.userColor = $auth.user.colorCode || '#561D5E';
posts = [...posts, data.post];
replyContent = '';
} else {
replyError = data.error || 'Failed to post reply';
}
} catch (e) {
replyError = 'Network error';
}
submittingReply = false;
}
async function deletePost(postId) {
if (!confirm('Are you sure you want to delete this post?')) return;
try {
const response = await fetch(`/api/forums/${$page.params.slug}/threads/${$page.params.threadId}/posts/${postId}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
posts = posts.filter(p => p.id !== postId);
}
} catch (e) {
console.error('Failed to delete post:', e);
}
}
async function deleteThread() {
if (!confirm('Are you sure you want to delete this entire thread?')) return;
try {
const response = await fetch(`/api/forums/${$page.params.slug}/threads/${$page.params.threadId}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
goto(`/forums/${$page.params.slug}`);
}
} catch (e) {
console.error('Failed to delete thread:', e);
}
}
function startEditPost(post) {
editingPostId = post.id;
editContent = post.content;
}
function cancelEditPost() {
editingPostId = null;
editContent = '';
}
async function saveEditPost(postId) {
if (!editContent.trim()) return;
try {
const response = await fetch(`/api/forums/${$page.params.slug}/threads/${$page.params.threadId}/posts/${postId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ content: editContent.trim() })
});
const data = await response.json();
if (response.ok && data.success) {
const idx = posts.findIndex(p => p.id === postId);
if (idx !== -1) {
posts[idx].content = editContent.trim();
posts[idx].isEdited = true;
posts = posts;
}
cancelEditPost();
}
} catch (e) {
console.error('Failed to edit post:', e);
}
}
function startEditThread() {
editingThread = true;
editThreadContent = thread.content;
}
function cancelEditThread() {
editingThread = false;
editThreadContent = '';
}
async function saveEditThread() {
if (!editThreadContent.trim()) return;
try {
const response = await fetch(`/api/forums/${$page.params.slug}/threads/${$page.params.threadId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: thread.title,
content: editThreadContent.trim()
})
});
const data = await response.json();
if (response.ok && data.success) {
thread.content = editThreadContent.trim();
cancelEditThread();
}
} catch (e) {
console.error('Failed to edit thread:', e);
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function handleUsernameClick(event, username, userId) {
event.preventDefault();
event.stopPropagation();
const rect = event.target.getBoundingClientRect();
activeProfilePreview = {
username,
userId,
isGuest: false,
position: { x: rect.left, y: rect.bottom + 5 }
};
}
function handleProfileClose() {
activeProfilePreview = null;
}
</script>
<svelte:head>
<title>{thread ? `${$siteSettings.site_title} - ${thread.title}` : $siteSettings.site_title}</title>
</svelte:head>
<style>
.thread-container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.breadcrumb {
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.breadcrumb a {
color: var(--gray);
text-decoration: none;
}
.breadcrumb a:hover {
color: var(--primary);
}
.breadcrumb span {
color: var(--gray);
}
.thread-header {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.thread-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--white);
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.thread-badge {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-weight: 500;
}
.badge-pinned {
background: #f59e0b;
color: #000;
}
.badge-locked {
background: var(--gray);
color: #000;
}
.thread-meta {
font-size: 0.85rem;
color: var(--gray);
margin-bottom: 1rem;
}
.thread-author {
cursor: pointer;
transition: filter 0.15s ease;
}
.thread-author:hover {
filter: invert(1);
}
.thread-content {
color: var(--white);
line-height: 1.7;
}
.thread-content :global(p) {
margin: 0.75rem 0;
}
.thread-content :global(blockquote) {
border-left: 3px solid #4ade80;
padding-left: 1rem;
margin: 0.75rem 0;
color: #4ade80;
}
.thread-content :global(.redtext) {
color: #ef4444;
}
.thread-content :global(.sticker-img) {
max-height: 128px;
vertical-align: middle;
}
.thread-content :global(code) {
background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: monospace;
}
.thread-content :global(pre) {
background: #0a0a0a;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
}
.thread-actions {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.5rem;
}
.posts-section {
margin-bottom: 2rem;
}
.posts-header {
color: var(--gray);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.post-card {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 0.5rem;
}
.post-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.post-author {
cursor: pointer;
font-weight: 500;
transition: filter 0.15s ease;
}
.post-author:hover {
filter: invert(1);
}
.post-date {
color: var(--gray);
font-size: 0.8rem;
}
.post-edited {
color: var(--gray);
font-size: 0.75rem;
font-style: italic;
}
.post-content {
color: var(--white);
line-height: 1.6;
}
.post-content :global(p) {
margin: 0.5rem 0;
}
.post-content :global(blockquote) {
border-left: 3px solid #4ade80;
padding-left: 1rem;
margin: 0.5rem 0;
color: #4ade80;
}
.post-content :global(.redtext) {
color: #ef4444;
}
.post-content :global(.sticker-img) {
max-height: 96px;
vertical-align: middle;
}
.post-actions {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
}
.action-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--gray);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.action-btn:hover {
border-color: var(--primary);
color: var(--white);
}
.action-btn.danger:hover {
border-color: var(--error);
color: var(--error);
}
.reply-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
.reply-section h3 {
margin: 0 0 1rem 0;
color: var(--white);
font-size: 1.1rem;
}
.reply-textarea {
width: 100%;
min-height: 120px;
background: #0a0a0a;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
color: var(--white);
font-family: inherit;
font-size: 1rem;
resize: vertical;
}
.reply-textarea:focus {
outline: none;
border-color: var(--primary);
}
.reply-actions {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.reply-help {
color: var(--gray);
font-size: 0.8rem;
}
.reply-error {
color: var(--error);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.locked-notice {
text-align: center;
padding: 2rem;
color: var(--gray);
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
}
.banned-notice {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
color: var(--error);
text-align: center;
}
.edit-textarea {
width: 100%;
min-height: 100px;
background: #0a0a0a;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
color: var(--white);
font-family: inherit;
font-size: 1rem;
resize: vertical;
margin-bottom: 0.5rem;
}
.edit-actions {
display: flex;
gap: 0.5rem;
}
.loading, .error {
text-align: center;
padding: 3rem;
color: var(--gray);
}
.error {
color: var(--error);
}
</style>
<div class="thread-container">
<div class="breadcrumb">
<a href="/forums">Forums</a>
<span> / </span>
<a href="/forums/{$page.params.slug}">{forum?.name || 'Forum'}</a>
<span> / </span>
<span>{thread?.title || 'Thread'}</span>
</div>
{#if loading}
<div class="loading">Loading...</div>
{:else if error}
<div class="error">{error}</div>
{:else if thread}
<div class="thread-header">
<h1 class="thread-title">
{#if thread.isPinned}
<span class="thread-badge badge-pinned">PINNED</span>
{/if}
{#if thread.isLocked}
<span class="thread-badge badge-locked">LOCKED</span>
{/if}
{thread.title}
</h1>
<div class="thread-meta">
Posted by <span
class="thread-author"
style="color: {thread.userColor || 'var(--primary)'};"
on:click={(e) => handleUsernameClick(e, thread.username, thread.userId)}
>@{thread.username}</span> on {formatDate(thread.createdAt)}
</div>
{#if editingThread}
<textarea class="edit-textarea" bind:value={editThreadContent}></textarea>
<div class="edit-actions">
<button class="action-btn" on:click={saveEditThread}>Save</button>
<button class="action-btn" on:click={cancelEditThread}>Cancel</button>
</div>
{:else}
<div class="thread-content">
{@html parsedThreadContent}
</div>
{/if}
{#if (canModerate || isThreadAuthor) && !editingThread}
<div class="thread-actions">
{#if isThreadAuthor}
<button class="action-btn" on:click={startEditThread}>Edit</button>
{/if}
<button class="action-btn danger" on:click={deleteThread}>Delete Thread</button>
</div>
{/if}
</div>
{#if posts.length > 0}
<div class="posts-section">
<div class="posts-header">{posts.length} {posts.length === 1 ? 'reply' : 'replies'}</div>
{#each posts as post}
<div class="post-card">
<div class="post-header">
<span>
<span
class="post-author"
style="color: {post.userColor || 'var(--primary)'};"
on:click={(e) => handleUsernameClick(e, post.username, post.userId)}
>@{post.username}</span>
{#if post.isEdited}
<span class="post-edited">(edited)</span>
{/if}
</span>
<span class="post-date">{formatDate(post.createdAt)}</span>
</div>
{#if editingPostId === post.id}
<textarea class="edit-textarea" bind:value={editContent}></textarea>
<div class="edit-actions">
<button class="action-btn" on:click={() => saveEditPost(post.id)}>Save</button>
<button class="action-btn" on:click={cancelEditPost}>Cancel</button>
</div>
{:else}
<div class="post-content">
{@html parsedPostsContent[post.id] || ''}
</div>
{#if $auth.user && (post.userId === $auth.user.id || canModerate)}
<div class="post-actions">
{#if post.userId === $auth.user.id}
<button class="action-btn" on:click={() => startEditPost(post)}>Edit</button>
{/if}
<button class="action-btn danger" on:click={() => deletePost(post.id)}>Delete</button>
</div>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
{#if isBanned}
<div class="banned-notice">
You are banned from this forum and cannot post replies.
</div>
{:else if thread.isLocked}
<div class="locked-notice">
This thread is locked. No new replies can be posted.
</div>
{:else}
<div class="reply-section">
<h3>Post a Reply</h3>
{#if replyError}
<div class="reply-error">{replyError}</div>
{/if}
<textarea
class="reply-textarea"
bind:value={replyContent}
placeholder="Write your reply... (Markdown and :stickers: supported)"
></textarea>
<div class="reply-actions">
<span class="reply-help">Supports Markdown and :sticker: syntax</span>
<button class="btn" on:click={submitReply} disabled={submittingReply}>
{submittingReply ? 'Posting...' : 'Post Reply'}
</button>
</div>
</div>
{/if}
{/if}
</div>
{#if activeProfilePreview}
<ProfilePreview
username={activeProfilePreview.username}
userId={activeProfilePreview.userId}
isGuest={activeProfilePreview.isGuest}
position={activeProfilePreview.position}
on:close={handleProfileClose}
/>
{/if}

View file

@ -0,0 +1,205 @@
<script>
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth';
import { nakama } from '$lib/stores/nakama';
import { goto } from '$app/navigation';
let leaderboard = [];
let loading = true;
let error = null;
onMount(async () => {
// Check if user is logged in
if (!$auth.user) {
goto('/login');
return;
}
try {
// Initialize Nakama
await nakama.init();
const authResult = await nakama.authenticate();
if (!authResult.success) {
error = 'Failed to connect to game server';
loading = false;
return;
}
// Get leaderboard
const lbResult = await nakama.getChessLeaderboard(10);
if (lbResult.success) {
leaderboard = lbResult.records || [];
}
loading = false;
} catch (e) {
console.error('Games page error:', e);
error = e.message;
loading = false;
}
});
</script>
<svelte:head>
<title>Games - Realms</title>
</svelte:head>
<div class="games-page">
<header class="games-header">
<h1>Games</h1>
<p class="subtitle">Play turn-based games with other users</p>
</header>
{#if loading}
<div class="loading">Loading games...</div>
{:else if error}
<div class="error">{error}</div>
{:else}
<div class="games-grid">
<!-- Chess960 Game Card -->
<a href="/games/chess" class="game-card">
<div class="game-icon">&#9822;</div>
<h2>Chess960</h2>
<p>Classic two-player strategy game with randomized starting positions</p>
</a>
</div>
<!-- Leaderboard Section -->
{#if leaderboard.length > 0}
<section class="leaderboard-section">
<h2>Chess ELO Leaderboard</h2>
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th>ELO</th>
</tr>
</thead>
<tbody>
{#each leaderboard as record, i}
<tr class:highlight={record.userId === $auth.user?.id}>
<td>#{record.rank || i + 1}</td>
<td>{record.username}</td>
<td>{record.elo}</td>
</tr>
{/each}
</tbody>
</table>
</section>
{/if}
{/if}
</div>
<style>
.games-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.games-header {
text-align: center;
margin-bottom: 2rem;
}
.games-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.subtitle {
color: #888;
font-size: 1.1rem;
}
.loading, .error {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
.error {
color: #ff4444;
}
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.game-card {
background: #1a1a2e;
border-radius: 12px;
padding: 2rem;
text-align: center;
transition: transform 0.2s, box-shadow 0.2s;
}
.game-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
border-color: #561D5E;
}
a.game-card {
text-decoration: none;
color: inherit;
border: 2px solid transparent;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
}
.game-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.game-card h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.game-card p {
color: #888;
margin-bottom: 0;
}
.leaderboard-section {
margin-top: 2rem;
}
.leaderboard-section h2 {
font-size: 1.3rem;
margin-bottom: 1rem;
border-bottom: 1px solid #333;
padding-bottom: 0.5rem;
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
}
.leaderboard-table th,
.leaderboard-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #333;
}
.leaderboard-table th {
background: #1a1a2e;
font-weight: 600;
}
.leaderboard-table tr.highlight {
background: rgba(86, 29, 94, 0.3);
}
.leaderboard-table tr:hover {
background: rgba(255, 255, 255, 0.05);
}
</style>

View file

@ -0,0 +1,891 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth';
import { nakama, ChessOpCode, GAMES_POLL_INTERVAL } from '$lib/stores/nakama';
import { gamesOverlay } from '$lib/stores/gamesOverlay';
// Lobby state
let openChallenges = [];
let liveGames = [];
let leaderboard = [];
let loading = true;
let error = null;
let refreshInterval = null;
let creatingGame = false;
let authReady = false; // Track when auth store has finished loading
let initialLoadDone = false; // Prevent multiple initial loads
// When ?match= param is present, redirect to overlay
$: matchIdFromUrl = $page.url.searchParams.get('match');
let joinAttempted = false;
$: if (browser && matchIdFromUrl && $auth.user && !$auth.loading && !joinAttempted) {
// Wait for auth to finish loading before joining
joinAttempted = true;
joinGameFromUrl(matchIdFromUrl);
}
// Wait for auth to finish loading before loading lobby data
$: if (browser && !$auth.loading && !initialLoadDone) {
authReady = true;
initialLoadDone = true;
loadLobbyData();
}
// Refresh lobby when game overlay closes (game finished)
let wasOverlayEnabled = false;
$: {
if (browser && wasOverlayEnabled && !$gamesOverlay.enabled) {
// Overlay just closed - refresh the lobby data
loadLobbyData();
}
wasOverlayEnabled = $gamesOverlay.enabled;
}
async function joinGameFromUrl(matchId) {
try {
await nakama.init();
const authResult = await nakama.authenticate();
if (!authResult.success) {
error = 'Failed to authenticate with game server';
return;
}
const socketResult = await nakama.connectSocket();
if (!socketResult.success) {
error = 'Failed to connect to game server';
return;
}
// Join the match
const joinResult = await nakama.joinMatch(matchId);
if (joinResult.success) {
// Open in overlay - don't force mode, let server GAME_STATE determine role
gamesOverlay.openGame(matchId, null);
// Clear URL param
goto('/games/chess', { replaceState: true });
} else {
error = `Failed to join match: ${joinResult.error}`;
}
} catch (e) {
error = `Error joining game: ${e.message}`;
}
}
// Helper to add timeout to promises
function withTimeout(promise, ms, fallback) {
return Promise.race([
promise,
new Promise(resolve => setTimeout(() => resolve(fallback), ms))
]);
}
async function loadLobbyData() {
if (!browser) return;
try {
// Initialize Nakama
await nakama.init();
// Always try to authenticate - nakama.authenticate() checks for token internally
const authResult = await nakama.authenticate();
if (!authResult.success) {
console.warn('Nakama auth failed:', authResult.error);
// Continue anyway to show public data, but matches won't load
}
// Fetch matches
const [waitingResult, playingResult, leaderboardResult] = await Promise.all([
withTimeout(nakama.listChessMatches(20, 'waiting'), 5000, { success: false, matches: [] }),
withTimeout(nakama.listChessMatches(20, 'playing'), 5000, { success: false, matches: [] }),
$auth.user
? withTimeout(nakama.getChessLeaderboard(10), 5000, { success: false, records: [] })
: { success: false }
]);
console.log('Chess lobby data:', { waitingResult, playingResult });
if (waitingResult.success) {
// Include all challenges (including user's own)
openChallenges = waitingResult.matches || [];
} else {
console.warn('Failed to fetch waiting matches:', waitingResult.error);
}
if (playingResult.success) {
// Filter out user's own match from live games
liveGames = (playingResult.matches || []).filter(m => !m.isCurrentUserMatch);
}
if (leaderboardResult.success) {
leaderboard = leaderboardResult.records || [];
}
loading = false;
} catch (e) {
console.error('Failed to load lobby data:', e);
error = 'Failed to load game data';
loading = false;
}
}
async function cancelChallenge(matchId) {
// Optimistic update - remove from list immediately
openChallenges = openChallenges.filter(c => c.matchId !== matchId);
try {
const result = await nakama.cancelChallenge(matchId);
if (!result.success) {
error = result.error || 'Failed to cancel challenge';
loadLobbyData(); // Refresh to restore correct state
}
} catch (e) {
error = `Error canceling game: ${e.message}`;
loadLobbyData(); // Refresh to restore correct state
}
}
async function createNewGame() {
if (!$auth.user) {
goto('/login');
return;
}
creatingGame = true;
try {
const result = await nakama.createChallenge();
if (result.success) {
// Optimistic update - add to list immediately
// Don't call loadLobbyData() right away as the server may not have
// updated the match label yet, causing a flash. The poll interval
// will sync with server data.
const newChallenge = {
matchId: result.matchId,
white: $auth.user.username,
black: null,
status: 'waiting',
createdAt: Math.floor(Date.now() / 1000),
isCurrentUserMatch: true
};
openChallenges = [newChallenge, ...openChallenges];
} else if (result.existingMatchId) {
// User already has an active challenge - refresh to show it
error = result.error;
loadLobbyData();
} else {
error = result.error || 'Failed to create game';
}
} catch (e) {
error = `Error creating game: ${e.message}`;
}
creatingGame = false;
}
async function joinChallenge(matchId) {
console.log('[ChessPage] joinChallenge:', matchId);
if (!$auth.user) {
goto('/login');
return;
}
try {
await nakama.init();
const authResult = await nakama.authenticate();
if (!authResult.success) {
error = 'Failed to authenticate';
return;
}
const socketResult = await nakama.connectSocket();
if (!socketResult.success) {
error = 'Failed to connect to server';
return;
}
console.log('[ChessPage] About to joinMatch...');
const joinResult = await nakama.joinMatch(matchId);
console.log('[ChessPage] joinMatch result:', joinResult.success);
if (joinResult.success) {
// Open overlay without setting mode - wait for server GAME_STATE to confirm role
// Server will send 'playing' if we're a player or 'spectating' if game is full
console.log('[ChessPage] Opening overlay for match:', matchId);
gamesOverlay.openGame(matchId, null);
} else {
error = `Failed to join: ${joinResult.error}`;
}
} catch (e) {
error = `Error joining game: ${e.message}`;
}
}
async function spectateGame(matchId) {
try {
await nakama.init();
// Spectating might work without full auth
if ($auth.user) {
await nakama.authenticate();
}
const socketResult = await nakama.connectSocket();
if (!socketResult.success) {
error = 'Failed to connect to server';
return;
}
const joinResult = await nakama.joinMatch(matchId);
if (joinResult.success) {
// Don't force mode - let server GAME_STATE determine if spectator or player
gamesOverlay.openGame(matchId, null);
} else {
error = `Failed to spectate: ${joinResult.error}`;
}
} catch (e) {
error = `Error spectating game: ${e.message}`;
}
}
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
onMount(() => {
if (!matchIdFromUrl) {
// Close any stale overlay when visiting lobby without a match param
gamesOverlay.closeGame();
// Note: Initial loadLobbyData is triggered by the reactive statement when auth finishes loading
refreshInterval = setInterval(loadLobbyData, GAMES_POLL_INTERVAL);
}
});
onDestroy(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<svelte:head>
<title>Chess960 - Realms</title>
</svelte:head>
<div class="chess-lobby">
<header class="lobby-header">
<div class="header-left">
<a href="/games" class="back-link">&larr; Games</a>
</div>
<div class="header-center">
<h1>Chess960</h1>
<span class="subtitle">Fischer Random</span>
</div>
<div class="header-right">
{#if !$auth.user}
<a href="/login" class="login-link">Login to Play</a>
{/if}
</div>
</header>
{#if error}
<div class="error-banner">
<span>{error}</span>
<button on:click={() => error = null}>×</button>
</div>
{/if}
{#if loading}
<div class="loading">
<div class="spinner"></div>
<span>Loading lobby...</span>
</div>
{:else}
<div class="lobby-content">
<!-- Open Challenges -->
<section class="lobby-section">
<div class="section-header">
<h2>Open Challenges</h2>
{#if $auth.user}
<button class="create-challenge-btn" on:click={createNewGame} disabled={creatingGame}>
{creatingGame ? 'Creating...' : '+ Create Challenge'}
</button>
{/if}
</div>
{#if openChallenges.length === 0}
<div class="empty-state">
<p>No open challenges right now</p>
</div>
{:else}
<div class="challenge-list">
{#each openChallenges as challenge}
<div class="challenge-card" class:own-challenge={challenge.isCurrentUserMatch}>
<div class="challenge-info">
<span class="player-name">{challenge.white || 'Anonymous'}</span>
{#if challenge.isCurrentUserMatch}
<span class="own-badge">you</span>
{:else}
<span class="waiting-text">is waiting...</span>
{/if}
<span class="created-time">{formatTime(challenge.createdAt)}</span>
</div>
<div class="challenge-actions">
{#if $auth.user}
{#if challenge.isCurrentUserMatch}
<button class="view-btn" on:click={() => joinChallenge(challenge.matchId)}>
Enter
</button>
<button class="cancel-btn" on:click={() => cancelChallenge(challenge.matchId)}>
Cancel
</button>
{:else}
<button class="join-btn" on:click={() => joinChallenge(challenge.matchId)}>
Join
</button>
{/if}
{:else}
<a href="/login" class="join-btn disabled">Login</a>
{/if}
{#if !challenge.isCurrentUserMatch}
<button class="popout-btn" on:click={() => spectateGame(challenge.matchId)} title="Open in popout">
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Live Games -->
<section class="lobby-section">
<h2>Live Games</h2>
{#if liveGames.length === 0}
<div class="empty-state">
<p>No games in progress</p>
</div>
{:else}
<div class="game-list">
{#each liveGames as game}
<div class="game-card">
<div class="game-players">
<span class="player white">{game.white || '?'}</span>
<span class="vs">vs</span>
<span class="player black">{game.black || '?'}</span>
</div>
<div class="game-meta">
{#if game.spectatorCount > 0}
<span class="spectators">
<span class="icon">👁</span>
{game.spectatorCount}
</span>
{/if}
<button class="popout-btn" on:click={() => spectateGame(game.matchId)} title="Watch in popout">
</button>
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Leaderboard -->
{#if $auth.user && leaderboard.length > 0}
<section class="lobby-section leaderboard-section">
<h2>Leaderboard</h2>
<div class="leaderboard">
{#each leaderboard as record, i}
<div class="leaderboard-row" class:top3={i < 3}>
<span class="rank">#{i + 1}</span>
<span class="username">{record.username || record.owner_id?.slice(0, 8)}</span>
<span class="elo">{record.score || 1200}</span>
</div>
{/each}
</div>
</section>
{/if}
<!-- Chess960 Info -->
<section class="lobby-section info-section">
<h2>About Chess960</h2>
<p>
Chess960, also known as Fischer Random Chess, is a variant where the starting
position of pieces on the home rank is randomized. This creates 960 possible
starting positions, emphasizing creativity and understanding over memorized
opening theory.
</p>
<div class="rules">
<h3>Rules:</h3>
<ul>
<li>Bishops must start on opposite colored squares</li>
<li>The King must start between the two Rooks</li>
<li>Black's pieces mirror White's setup</li>
<li>All other standard chess rules apply</li>
</ul>
</div>
</section>
</div>
{/if}
</div>
<style>
.chess-lobby {
max-width: 900px;
margin: 0 auto;
padding: 1rem;
}
.lobby-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #30363d;
}
.header-left,
.header-right {
flex: 1;
display: flex;
align-items: center;
}
.header-left {
justify-content: flex-start;
}
.header-right {
justify-content: flex-end;
}
.header-center {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.back-link {
color: #8b949e;
text-decoration: none;
font-size: 0.9rem;
}
.back-link:hover {
color: #c9d1d9;
}
.lobby-header h1 {
margin: 0;
font-size: 1.5rem;
color: #c9d1d9;
}
.subtitle {
color: #6e7681;
font-size: 0.85rem;
}
.login-link {
color: #8b949e;
text-decoration: none;
padding: 0.6rem 1.2rem;
border: 1px solid #30363d;
border-radius: 6px;
}
.login-link:hover {
color: #c9d1d9;
border-color: #484f58;
}
.error-banner {
background: rgba(248, 81, 73, 0.15);
border: 1px solid rgba(248, 81, 73, 0.4);
color: #f85149;
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.error-banner button {
background: none;
border: none;
color: #f85149;
cursor: pointer;
font-size: 1.2rem;
padding: 0;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
color: #8b949e;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #30363d;
border-top-color: #f59e0b;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.lobby-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.lobby-section {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 1rem;
}
.cancel-btn {
background: rgba(248, 81, 73, 0.15);
border: 1px solid rgba(248, 81, 73, 0.3);
color: #f85149;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.15s ease;
}
.cancel-btn:hover {
background: rgba(248, 81, 73, 0.25);
border-color: #f85149;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 {
margin: 0;
}
.lobby-section h2 {
margin: 0 0 1rem 0;
font-size: 1rem;
color: #c9d1d9;
font-weight: 600;
}
.create-challenge-btn {
background: #f59e0b;
color: #000;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
}
.create-challenge-btn:hover:not(:disabled) {
background: #fbbf24;
}
.create-challenge-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.empty-state {
text-align: center;
padding: 1.5rem;
color: #6e7681;
}
.empty-state p {
margin: 0;
}
.challenge-list,
.game-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.challenge-card,
.game-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #0d1117;
border-radius: 6px;
}
.challenge-card.own-challenge {
background: rgba(34, 197, 94, 0.05);
border-left: 3px solid #22c55e;
}
.own-badge {
color: #22c55e;
font-size: 0.75rem;
font-weight: 600;
}
.view-btn {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #22c55e;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.view-btn:hover {
background: rgba(34, 197, 94, 0.25);
border-color: #22c55e;
}
.challenge-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.challenge-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.player-name {
color: #c9d1d9;
font-weight: 500;
}
.waiting-text {
color: #8b949e;
font-size: 0.85rem;
}
.created-time {
color: #6e7681;
font-size: 0.75rem;
}
.join-btn,
.watch-btn {
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
text-decoration: none;
}
.join-btn:hover {
background: #30363d;
border-color: #484f58;
}
.join-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.popout-btn {
background: #21262d;
border: 1px solid #30363d;
color: #f59e0b;
padding: 0.4rem 0.6rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.15s ease;
}
.popout-btn:hover {
background: #30363d;
border-color: #f59e0b;
}
.game-players {
display: flex;
align-items: center;
gap: 0.5rem;
}
.player {
font-weight: 500;
}
.player.white {
color: #f0d9b5;
}
.player.black {
color: #b58863;
}
.vs {
color: #6e7681;
font-size: 0.8rem;
}
.game-meta {
display: flex;
align-items: center;
gap: 0.75rem;
}
.spectators {
display: flex;
align-items: center;
gap: 0.25rem;
color: #8b949e;
font-size: 0.8rem;
}
.spectators .icon {
font-size: 0.9rem;
}
.leaderboard-section {
max-width: 400px;
}
.leaderboard {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.leaderboard-row {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background: #0d1117;
border-radius: 4px;
}
.leaderboard-row.top3 {
background: rgba(245, 158, 11, 0.1);
}
.rank {
width: 40px;
color: #6e7681;
font-size: 0.85rem;
}
.leaderboard-row.top3 .rank {
color: #f59e0b;
font-weight: 600;
}
.username {
flex: 1;
color: #c9d1d9;
}
.elo {
color: #8b949e;
font-family: monospace;
font-size: 0.9rem;
}
.info-section {
background: #0d1117;
}
.info-section p {
color: #8b949e;
font-size: 0.9rem;
line-height: 1.6;
margin: 0 0 1rem 0;
}
.rules h3 {
font-size: 0.85rem;
color: #c9d1d9;
margin: 0 0 0.5rem 0;
}
.rules ul {
margin: 0;
padding-left: 1.25rem;
color: #8b949e;
font-size: 0.85rem;
}
.rules li {
margin-bottom: 0.25rem;
}
@media (max-width: 600px) {
.lobby-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.header-right {
width: 100%;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.create-challenge-btn {
width: 100%;
}
.challenge-card,
.game-card {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.challenge-actions {
width: 100%;
}
.join-btn {
flex: 1;
text-align: center;
}
}
</style>

View file

@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import { auth, isAuthenticated } from '$lib/stores/auth';
import { siteSettings } from '$lib/stores/siteSettings';
import { goto } from '$app/navigation';
import * as pgp from '$lib/pgp';
import '../../app.css';
@ -28,14 +29,35 @@
// Show PGP command example
let showPgpExample = false;
// For unlocking stored key
let storedKeyPassphrase = '';
// Referral registration
let referralMode = false;
let referralCode = '';
let referralValidating = false;
let referralValid = false;
let registrationEnabled = true;
let referralSystemEnabled = false;
onMount(async () => {
await auth.init();
if ($isAuthenticated) {
goto('/');
return;
}
// Check registration and referral settings
try {
const response = await fetch('/api/settings/referral');
const data = await response.json();
if (data.success) {
registrationEnabled = data.registrationEnabled;
referralSystemEnabled = data.referralSystemEnabled;
}
} catch (e) {
console.error('Error loading registration settings', e);
}
});
@ -152,11 +174,103 @@
loading = false;
}
// Referral code functions
async function validateReferralCode() {
if (!referralCode.trim()) {
error = 'Please enter a referral code';
return;
}
referralValidating = true;
error = '';
try {
const response = await fetch('/api/auth/validate-referral', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: referralCode.trim() })
});
const data = await response.json();
if (data.success && data.valid) {
referralValid = true;
mode = 'register';
} else {
error = 'Invalid or already used referral code';
}
} catch (e) {
error = 'Error validating code';
console.error(e);
}
referralValidating = false;
}
async function handleReferralRegister() {
error = '';
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
const passwordError = validatePassword(password);
if (passwordError) {
error = passwordError;
return;
}
if (keyPassphrase !== confirmKeyPassphrase) {
error = 'Key passphrases do not match';
return;
}
const passphraseError = pgp.validatePassphrase(keyPassphrase);
if (passphraseError) {
error = passphraseError;
return;
}
loading = true;
try {
const keyPair = await pgp.generateKeyPair(username, keyPassphrase);
const response = await fetch('/api/auth/register-referral', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: referralCode.trim(),
username,
password,
publicKey: keyPair.publicKey,
fingerprint: keyPair.fingerprint
})
});
const data = await response.json();
if (data.success) {
generatedPrivateKey = keyPair.privateKey;
generatedPublicKey = keyPair.publicKey;
showGeneratedKeys = true;
} else {
error = data.error || 'Registration failed';
}
} catch (e) {
error = e.message || 'Failed to register';
console.error(e);
}
loading = false;
}
async function initiatePgpLogin() {
error = '';
pgpLoading = true;
try {
const response = await fetch('/api/auth/pgp-challenge', {
method: 'POST',
@ -258,6 +372,10 @@
}
</script>
<svelte:head>
<title>{$siteSettings.site_title} - Login</title>
</svelte:head>
<div class="auth-container">
{#if showGeneratedKeys}
<h1>Your PGP Keys</h1>
@ -493,8 +611,52 @@ iQEcBAABCAAGBQJe...
Back
</button>
</form>
{:else if referralMode && !referralValid}
<!-- Referral code entry screen -->
<div class="card" style="max-width: 400px; margin: 0 auto;">
<h2 style="margin-bottom: 1rem;">Enter Referral Code</h2>
<p style="color: var(--gray); margin-bottom: 1.5rem;">
Registration is invite-only. Please enter your referral code to continue.
</p>
<div class="form-group">
<label for="referral-code">Referral Code</label>
<input
type="text"
id="referral-code"
bind:value={referralCode}
placeholder="Enter code (e.g., ABC123DEF456)"
style="font-family: monospace; text-transform: uppercase; letter-spacing: 2px;"
disabled={referralValidating}
/>
</div>
{#if error}
<div class="error" style="margin-bottom: 1rem;">{error}</div>
{/if}
<button
class="btn-block"
on:click={validateReferralCode}
disabled={referralValidating || !referralCode.trim()}
>
{referralValidating ? 'Validating...' : 'Continue'}
</button>
<div style="margin-top: 1rem; text-align: center;">
<button class="btn-link" on:click={() => { referralMode = false; error = ''; referralCode = ''; }}>
Back to Login
</button>
</div>
</div>
{:else}
<form on:submit|preventDefault={handleRegister}>
<form on:submit|preventDefault={referralValid ? handleReferralRegister : handleRegister}>
{#if referralValid}
<div class="referral-badge" style="background: rgba(40, 167, 69, 0.1); border: 1px solid rgba(40, 167, 69, 0.3); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.5rem;">
<span style="color: #28a745;">Registering with code:</span>
<code style="font-family: monospace; letter-spacing: 2px; background: var(--black); padding: 0.25rem 0.5rem; border-radius: 4px;">{referralCode.toUpperCase()}</code>
</div>
{/if}
<div class="form-group">
<label for="reg-username">Username</label>
<input
@ -507,7 +669,7 @@ iQEcBAABCAAGBQJe...
title="Letters, numbers, and underscores only"
/>
</div>
<div class="form-group">
<label for="reg-password">Password</label>
<input
@ -583,11 +745,23 @@ iQEcBAABCAAGBQJe...
<div style="margin-top: 2rem; text-align: center;">
{#if mode === 'login'}
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; username = ''; password = ''; }}>
Need an account? Register
{#if registrationEnabled}
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; username = ''; password = ''; }}>
Need an account? Register
</button>
{:else if referralSystemEnabled}
<button class="btn-link" on:click={() => { mode = 'register'; referralMode = true; error = ''; }}>
Have a referral code? Register
</button>
{:else}
<p style="color: var(--gray);">Registration is currently closed</p>
{/if}
{:else if !referralMode}
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; username = ''; password = ''; keyPassphrase = ''; confirmKeyPassphrase = ''; referralValid = false; referralCode = ''; }}>
Already have an account? Login
</button>
{:else}
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; username = ''; password = ''; keyPassphrase = ''; confirmKeyPassphrase = ''; }}>
<button class="btn-link" on:click={() => { mode = 'login'; referralMode = false; error = ''; username = ''; password = ''; keyPassphrase = ''; confirmKeyPassphrase = ''; referralValid = false; referralCode = ''; }}>
Already have an account? Login
</button>
{/if}

File diff suppressed because it is too large Load diff

View file

@ -2,27 +2,30 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { auth, isAuthenticated } from '$lib/stores/auth';
import { siteSettings } from '$lib/stores/siteSettings';
import { formatUbercoin } from '$lib/stores/ubercoin';
import UbercoinTipModal from '$lib/components/UbercoinTipModal.svelte';
let profile = null;
let pgpKeys = [];
let realms = [];
let loading = true;
let realmsLoading = true;
let error = '';
let isOwnProfile = false;
let activeTab = 'bio';
let expandedKeys = {}; // Track which keys are expanded
let showTipModal = false;
// Reactive check for own profile
$: isOwnProfile = $isAuthenticated && $auth.user && profile && $auth.user.username === profile.username;
onMount(async () => {
// No authentication required - profile is public
const username = $page.params.username;
// Check if viewing own profile (only if authenticated)
if ($isAuthenticated && $auth.user) {
isOwnProfile = $auth.user.username === username;
}
await loadProfile(username);
await loadPgpKeys(username);
await Promise.all([loadPgpKeys(username), loadUserRealms(username)]);
loading = false;
});
@ -54,7 +57,7 @@
try {
// Public endpoint - no auth header needed
const response = await fetch(`/api/users/${username}/pgp-keys`);
if (response.ok) {
const data = await response.json();
pgpKeys = data.keys;
@ -67,6 +70,32 @@
console.error('Failed to load PGP keys:', e);
}
}
async function loadUserRealms(username) {
try {
const response = await fetch(`/api/realms/user/${username}`);
if (response.ok) {
const data = await response.json();
realms = data.realms || [];
}
} catch (e) {
console.error('Failed to load user realms:', e);
} finally {
realmsLoading = false;
}
}
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) + ' min ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
return date.toLocaleDateString();
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
@ -76,7 +105,26 @@
function toggleKey(fingerprint) {
expandedKeys[fingerprint] = !expandedKeys[fingerprint];
}
function handleOpenTipModal() {
showTipModal = true;
}
function handleTipModalClose() {
showTipModal = false;
}
function handleTipSent(event) {
showTipModal = false;
// Optionally refresh profile to show updated balance
if (profile) {
loadProfile(profile.username);
}
}
// Check if user can send tips
$: canSendTip = $isAuthenticated && $auth.user && !isOwnProfile;
function formatDateTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
@ -92,6 +140,10 @@
}
</script>
<svelte:head>
<title>{profile ? `${$siteSettings.site_title} - ${profile.username}` : $siteSettings.site_title}</title>
</svelte:head>
<style>
.profile-header {
display: flex;
@ -142,36 +194,43 @@
font-size: 0.9rem;
}
.badges-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.color-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
background: rgba(255, 255, 255, 0.05);
padding: 0.25rem 0.75rem;
padding: 0.3rem 0.6rem;
border-radius: 20px;
font-size: 0.85rem;
margin-top: 0.5rem;
font-size: 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.color-dot {
width: 16px;
height: 16px;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
}
.pgp-only-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(40, 167, 69, 0.2);
gap: 0.4rem;
background: rgba(40, 167, 69, 0.1);
color: var(--success);
padding: 0.25rem 0.75rem;
padding: 0.3rem 0.6rem;
border-radius: 20px;
font-size: 0.85rem;
margin-top: 0.5rem;
font-size: 0.8rem;
border: 1px solid rgba(40, 167, 69, 0.3);
}
.tab-nav {
@ -232,7 +291,31 @@
color: var(--gray);
font-style: italic;
}
.graffiti-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.graffiti-section h3 {
margin-bottom: 1rem;
}
.graffiti-container {
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
display: inline-block;
}
.graffiti-img {
image-rendering: pixelated;
width: 88px;
height: 33px;
display: block;
}
.pgp-section {
padding: 1.5rem;
background: #111;
@ -364,6 +447,166 @@
color: var(--gray);
font-style: italic;
}
/* Realms Section Styles */
.realms-section {
padding: 1.5rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
}
.realms-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.25rem;
}
.realm-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1rem;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.realm-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(86, 29, 94, 0.3);
border-color: var(--primary);
}
.realm-card.stream-realm:hover {
border-color: #28a745;
}
.realm-card.video-realm:hover {
border-color: #8b5cf6;
}
.realm-header-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.realm-type-badge {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.realm-type-badge.stream {
background: rgba(40, 167, 69, 0.2);
color: #28a745;
}
.realm-type-badge.video {
background: rgba(139, 92, 246, 0.2);
color: #8b5cf6;
}
.live-badge {
background: #dc3545;
color: white;
font-size: 0.65rem;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-weight: 700;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.realm-card h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
line-height: 1.3;
}
.realm-meta {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--gray);
font-size: 0.8rem;
}
.realm-meta .dot {
opacity: 0.5;
}
.no-realms {
text-align: center;
padding: 3rem;
color: var(--gray);
}
.no-realms-icon {
font-size: 3rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
/* Ubercoin Styles */
.ubercoin-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: rgba(255, 215, 0, 0.05);
border: 1px solid rgba(255, 215, 0, 0.2);
padding: 0.3rem 0.6rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
color: #ffd700;
}
.coin-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border-radius: 50%;
font-size: 0.55rem;
font-weight: bold;
color: #000;
}
.tip-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border: none;
border-radius: 6px;
color: #000;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 0.75rem;
}
.tip-button:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.tip-button .coin-icon {
width: 18px;
height: 18px;
font-size: 0.65rem;
}
</style>
<div class="container">
@ -391,29 +634,48 @@
<p class="member-since">
Member since {new Date(profile.createdAt).toLocaleDateString()}
</p>
<div class="color-badge">
<span class="color-dot" style="background: {profile.colorCode || '#561D5E'}"></span>
<span style="font-family: monospace;">{profile.colorCode || '#561D5E'}</span>
</div>
{#if profile.isPgpOnly}
<div class="pgp-only-badge">
<span>🔐</span>
<span>PGP-Only Authentication</span>
<div class="badges-row">
<div class="color-badge">
<span class="color-dot" style="background: {profile.colorCode || '#561D5E'}"></span>
<span style="font-family: monospace;">{profile.colorCode || '#561D5E'}</span>
</div>
<div class="ubercoin-badge">
<span class="coin-icon">Ü</span>
<span>{formatUbercoin(profile.ubercoinBalance)} übercoin</span>
</div>
{#if profile.isPgpOnly}
<div class="pgp-only-badge">
<span>🔐</span>
<span>PGP-Only</span>
</div>
{/if}
</div>
{#if canSendTip}
<button class="tip-button" on:click={handleOpenTipModal}>
<span class="coin-icon">Ü</span>
Send übercoin
</button>
{/if}
</div>
</div>
<div class="tab-nav">
<button
class="tab-button"
<button
class="tab-button"
class:active={activeTab === 'bio'}
on:click={() => activeTab = 'bio'}
>
Bio
</button>
<button
class="tab-button"
<button
class="tab-button"
class:active={activeTab === 'realms'}
on:click={() => activeTab = 'realms'}
>
Realms ({realms.length})
</button>
<button
class="tab-button"
class:active={activeTab === 'pgp'}
on:click={() => activeTab = 'pgp'}
>
@ -429,6 +691,67 @@
{:else}
<p class="no-bio">No bio yet</p>
{/if}
{#if profile.graffitiUrl}
<div class="graffiti-section">
<h3>Graffiti</h3>
<div class="graffiti-container">
<img
src={profile.graffitiUrl}
alt="{profile.username}'s graffiti"
class="graffiti-img"
/>
</div>
</div>
{/if}
</div>
{:else if activeTab === 'realms'}
<div class="realms-section">
{#if realmsLoading}
<p style="text-align: center; padding: 2rem;">Loading realms...</p>
{:else if realms.length === 0}
<div class="no-realms">
<div class="no-realms-icon">🏰</div>
<p>No realms created yet</p>
</div>
{:else}
<div class="realms-grid">
{#each realms as realm}
<a
href={realm.type === 'video' ? `/video/realm/${realm.id}` : realm.type === 'audio' ? `/audio/realm/${realm.id}` : `/${realm.name}/live`}
class="realm-card"
class:stream-realm={realm.type === 'stream' || !realm.type}
class:video-realm={realm.type === 'video'}
class:audio-realm={realm.type === 'audio'}
>
<div class="realm-header-info">
<span class="realm-type-badge {realm.type || 'stream'}">
{realm.type === 'video' ? '🎬 Video' : realm.type === 'audio' ? '🎵 Audio' : '📺 Stream'}
</span>
{#if realm.type !== 'video' && realm.isLive}
<span class="live-badge">LIVE</span>
{/if}
</div>
<h4 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h4>
<div class="realm-meta">
{#if realm.type === 'video'}
<span>{realm.videoCount || 0} videos</span>
{:else if realm.type === 'audio'}
<span>{realm.audioCount || 0} tracks</span>
{:else}
{#if realm.isLive}
<span>{realm.viewerCount || 0} viewers</span>
{:else}
<span>Offline</span>
{/if}
{/if}
<span class="dot">·</span>
<span>{timeAgo(realm.createdAt)}</span>
</div>
</a>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'pgp'}
<div class="pgp-section">
@ -500,4 +823,14 @@
</div>
{/if}
{/if}
</div>
</div>
<!-- Ubercoin Tip Modal -->
{#if profile}
<UbercoinTipModal
show={showTipModal}
recipientUsername={profile.username}
on:close={handleTipModalClose}
on:sent={handleTipSent}
/>
{/if}

View file

@ -0,0 +1,424 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { siteSettings } from '$lib/stores/siteSettings';
let readerContainer;
let ebook = null;
let loading = true;
let error = null;
let book = null;
let rendition = null;
let toc = [];
let currentLocation = null;
let showToc = false;
let progress = 0;
$: ebookId = $page.params.id;
async function loadEbook() {
if (!browser || !ebookId) return;
try {
// Fetch ebook metadata
const metaRes = await fetch(`/api/ebooks/${ebookId}`);
if (!metaRes.ok) {
if (metaRes.status === 404) {
error = 'Ebook not found';
} else {
error = 'Failed to load ebook';
}
loading = false;
return;
}
const data = await metaRes.json();
ebook = data.ebook;
// Increment read count
fetch(`/api/ebooks/${ebookId}/read`, { method: 'POST' }).catch(() => {});
// Dynamically import foliate-js to avoid SSR issues
const { EPUB } = await import('foliate-js/epub.js');
// Fetch the EPUB file
const fileRes = await fetch(ebook.filePath);
if (!fileRes.ok) {
error = 'Failed to load ebook file';
loading = false;
return;
}
const blob = await fileRes.blob();
// Initialize reader
book = await new EPUB({ blob }).init();
toc = book.toc || [];
if (readerContainer) {
// Create the view
rendition = book.render(readerContainer);
// Restore saved position
const savedPosition = localStorage.getItem(`ebook-pos-${ebookId}`);
if (savedPosition) {
try {
rendition.goTo(savedPosition);
} catch (e) {
console.warn('Could not restore position:', e);
}
}
// Listen for location changes
rendition.addEventListener('relocate', (e) => {
currentLocation = e.detail;
progress = e.detail.fraction ? Math.round(e.detail.fraction * 100) : 0;
// Save position
if (e.detail.cfi) {
localStorage.setItem(`ebook-pos-${ebookId}`, e.detail.cfi);
}
});
}
loading = false;
} catch (e) {
console.error('Failed to load ebook:', e);
error = 'Failed to load ebook: ' + e.message;
loading = false;
}
}
function goToTocItem(href) {
if (rendition && href) {
rendition.goTo(href);
showToc = false;
}
}
function prevPage() {
if (rendition) {
rendition.prev();
}
}
function nextPage() {
if (rendition) {
rendition.next();
}
}
function handleKeydown(event) {
if (event.key === 'ArrowLeft') {
prevPage();
} else if (event.key === 'ArrowRight') {
nextPage();
}
}
onMount(() => {
loadEbook();
if (browser) {
window.addEventListener('keydown', handleKeydown);
}
});
onDestroy(() => {
if (browser) {
window.removeEventListener('keydown', handleKeydown);
}
if (book) {
book.destroy?.();
}
});
</script>
<svelte:head>
<title>{ebook ? `${$siteSettings.site_title} - ${ebook.title}` : $siteSettings.site_title}</title>
</svelte:head>
<style>
.reader-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #1a1a1a;
display: flex;
flex-direction: column;
z-index: 1000;
}
.reader-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #111;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
}
.back-btn {
display: flex;
align-items: center;
gap: 0.5rem;
color: #3b82f6;
text-decoration: none;
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: background 0.2s;
}
.back-btn:hover {
background: rgba(59, 130, 246, 0.1);
}
.book-title {
font-size: 0.9rem;
color: var(--white);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 1rem;
}
.progress-indicator {
font-size: 0.85rem;
color: #3b82f6;
font-weight: 500;
}
.toc-btn {
padding: 0.5rem 0.75rem;
background: rgba(59, 130, 246, 0.2);
border: 1px solid rgba(59, 130, 246, 0.4);
border-radius: 6px;
color: #3b82f6;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.toc-btn:hover {
background: rgba(59, 130, 246, 0.3);
}
.reader-main {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.reader-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
overflow: auto;
}
.reader-container :global(*) {
max-width: 100%;
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 50px;
height: 100px;
background: rgba(0, 0, 0, 0.3);
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
}
.nav-btn:hover {
opacity: 1;
background: rgba(59, 130, 246, 0.5);
}
.reader-main:hover .nav-btn {
opacity: 0.5;
}
.nav-prev {
left: 0;
border-radius: 0 8px 8px 0;
}
.nav-next {
right: 0;
border-radius: 8px 0 0 8px;
}
.toc-sidebar {
position: absolute;
top: 0;
right: 0;
width: 300px;
height: 100%;
background: #111;
border-left: 1px solid #333;
overflow-y: auto;
z-index: 20;
transform: translateX(100%);
transition: transform 0.2s ease;
}
.toc-sidebar.open {
transform: translateX(0);
}
.toc-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #333;
}
.toc-header h3 {
margin: 0;
font-size: 1rem;
}
.toc-close {
background: none;
border: none;
color: var(--gray);
font-size: 1.5rem;
cursor: pointer;
}
.toc-list {
padding: 0.5rem 0;
}
.toc-item {
display: block;
padding: 0.75rem 1rem;
color: var(--white);
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
}
.toc-item:hover {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.loading-state,
.error-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--gray);
}
.error-state {
color: #ef4444;
}
.error-state h2 {
margin-bottom: 0.5rem;
}
.keyboard-hint {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
color: #666;
font-size: 0.75rem;
padding: 0.5rem 1rem;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
}
</style>
<div class="reader-page">
<div class="reader-toolbar">
<div class="toolbar-left">
<a href="/ebooks" class="back-btn">
<span>&#8592;</span> Back
</a>
{#if ebook}
<span class="book-title">{ebook.title}</span>
{/if}
</div>
<div class="toolbar-right">
<span class="progress-indicator">{progress}%</span>
{#if toc.length > 0}
<button class="toc-btn" on:click={() => showToc = !showToc}>
Contents
</button>
{/if}
</div>
</div>
<div class="reader-main">
{#if loading}
<div class="loading-state">
<p>Loading ebook...</p>
</div>
{:else if error}
<div class="error-state">
<h2>Error</h2>
<p>{error}</p>
<a href="/ebooks" class="back-btn" style="margin-top: 1rem;">
Return to library
</a>
</div>
{:else}
<button class="nav-btn nav-prev" on:click={prevPage} title="Previous page">&#8249;</button>
<div bind:this={readerContainer} class="reader-container"></div>
<button class="nav-btn nav-next" on:click={nextPage} title="Next page">&#8250;</button>
<div class="toc-sidebar" class:open={showToc}>
<div class="toc-header">
<h3>Table of Contents</h3>
<button class="toc-close" on:click={() => showToc = false}>×</button>
</div>
<div class="toc-list">
{#each toc as item}
<button class="toc-item" on:click={() => goToTocItem(item.href)}>
{item.label}
</button>
{/each}
</div>
</div>
<div class="keyboard-hint">
Use &#8592; &#8594; arrow keys to navigate
</div>
{/if}
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,225 @@
<script>
import { onMount } from 'svelte';
import { siteSettings } from '$lib/stores/siteSettings';
let stickerStats = [];
let totalUsage = 0;
let loading = true;
let error = '';
onMount(async () => {
await loadStickerStats();
});
async function loadStickerStats() {
loading = true;
error = '';
try {
const response = await fetch('/api/stats/stickers?limit=50');
if (response.ok) {
const data = await response.json();
if (data.success) {
stickerStats = data.stickers || [];
totalUsage = data.totalUsage || 0;
} else {
error = 'Failed to load sticker statistics';
}
} else {
error = 'Failed to load sticker statistics';
}
} catch (e) {
error = 'Error loading statistics';
console.error(e);
} finally {
loading = false;
}
}
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
</script>
<svelte:head>
<title>Statistics - {$siteSettings.title || 'Stream'}</title>
</svelte:head>
<div class="stats-container">
<h1>Site Statistics</h1>
<section class="stats-section">
<h2>Top Stickers</h2>
<p class="total-usage">Total sticker uses: <span class="highlight">{formatNumber(totalUsage)}</span></p>
{#if loading}
<div class="loading">Loading statistics...</div>
{:else if error}
<div class="error">{error}</div>
{:else if stickerStats.length === 0}
<div class="empty">No sticker usage data yet. Start chatting with stickers!</div>
{:else}
<div class="sticker-leaderboard">
{#each stickerStats as sticker, index}
<div class="sticker-row" class:top-1={index === 0} class:top-2={index === 1} class:top-3={index === 2}>
<div class="usage-bar" style="width: {(sticker.usageCount / stickerStats[0].usageCount) * 100}%"></div>
<span class="rank">#{index + 1}</span>
<img src={sticker.filePath} alt={sticker.name} class="sticker-preview" />
<span class="sticker-name">:{sticker.name}:</span>
<span class="usage-count">{formatNumber(sticker.usageCount)}</span>
</div>
{/each}
</div>
{/if}
</section>
</div>
<style>
.stats-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
color: #fff;
margin-bottom: 2rem;
font-size: 1.8rem;
}
.stats-section {
background: #111;
border-radius: 8px;
padding: 1.5rem;
border: 1px solid #333;
}
h2 {
color: var(--primary, #561d5e);
margin-bottom: 0.5rem;
font-size: 1.3rem;
}
.total-usage {
color: #888;
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
.total-usage .highlight {
color: var(--primary, #561d5e);
font-weight: 600;
}
.sticker-leaderboard {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sticker-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: #1a1a1a;
border-radius: 4px;
position: relative;
overflow: hidden;
border: 1px solid transparent;
}
.sticker-row.top-1 {
border-color: #ffd700;
background: linear-gradient(90deg, rgba(255, 215, 0, 0.1) 0%, #1a1a1a 50%);
}
.sticker-row.top-2 {
border-color: #c0c0c0;
background: linear-gradient(90deg, rgba(192, 192, 192, 0.1) 0%, #1a1a1a 50%);
}
.sticker-row.top-3 {
border-color: #cd7f32;
background: linear-gradient(90deg, rgba(205, 127, 50, 0.1) 0%, #1a1a1a 50%);
}
.usage-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: rgba(86, 29, 94, 0.15);
z-index: 0;
pointer-events: none;
}
.rank {
color: #666;
font-weight: 600;
width: 2.5rem;
z-index: 1;
font-size: 0.9rem;
}
.sticker-row.top-1 .rank { color: #ffd700; }
.sticker-row.top-2 .rank { color: #c0c0c0; }
.sticker-row.top-3 .rank { color: #cd7f32; }
.sticker-preview {
width: 32px;
height: 32px;
object-fit: contain;
z-index: 1;
}
.sticker-name {
flex: 1;
color: #ccc;
font-family: monospace;
z-index: 1;
font-size: 0.95rem;
}
.usage-count {
color: var(--primary, #561d5e);
font-weight: 600;
z-index: 1;
font-size: 0.95rem;
min-width: 4rem;
text-align: right;
}
.loading, .empty {
color: #666;
text-align: center;
padding: 3rem;
font-size: 0.95rem;
}
.error {
color: var(--error, #dc3545);
text-align: center;
padding: 2rem;
}
@media (max-width: 600px) {
.stats-container {
padding: 1rem;
}
.sticker-row {
padding: 0.5rem 0.75rem;
gap: 0.5rem;
}
.sticker-name {
font-size: 0.85rem;
}
.rank {
width: 2rem;
}
}
</style>

View file

@ -0,0 +1,371 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { siteSettings } from '$lib/stores/siteSettings';
import { formatDuration, formatViews, timeAgo } from '$lib/utils/formatters';
let videos = [];
let loading = true;
let page = 1;
let hasMore = true;
// Group videos by realm
$: groupedVideos = videos.reduce((acc, video) => {
const realmKey = video.realmId || 'unknown';
if (!acc[realmKey]) {
acc[realmKey] = {
realmId: video.realmId,
realmName: video.realmName || 'Unknown Realm',
videos: []
};
}
acc[realmKey].videos.push(video);
return acc;
}, {});
$: realmGroups = Object.values(groupedVideos);
async function loadVideos(append = false) {
if (!browser) return;
try {
const res = await fetch(`/api/videos?page=${page}&limit=20`);
if (res.ok) {
const data = await res.json();
const fetchedVideos = data.videos || [];
if (append) {
videos = [...videos, ...fetchedVideos];
} else {
videos = fetchedVideos;
}
hasMore = fetchedVideos.length === 20;
}
} catch (e) {
console.error('Failed to load videos:', e);
} finally {
loading = false;
}
}
function loadMore() {
page++;
loadVideos(true);
}
onMount(() => {
loadVideos();
});
</script>
<svelte:head>
<title>{$siteSettings.site_title} - Videos</title>
</svelte:head>
<style>
.hero {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
}
.hero h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #8b5cf6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.1rem;
color: var(--gray);
}
/* Realm group styles */
.realm-group {
margin-bottom: 3rem;
}
.realm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.realm-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.realm-title h2 {
margin: 0;
font-size: 1.3rem;
}
.realm-badge {
background: rgba(139, 92, 246, 0.2);
color: #8b5cf6;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.view-realm-link {
color: #8b5cf6;
text-decoration: none;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.3rem;
transition: opacity 0.2s;
}
.view-realm-link:hover {
opacity: 0.8;
text-decoration: underline;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.video-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.3);
border-color: #8b5cf6;
}
.video-thumbnail {
width: 100%;
aspect-ratio: 16/9;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.video-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-thumbnail .thumb-static {
position: relative;
z-index: 1;
}
.video-thumbnail .thumb-preview {
position: absolute;
top: 0;
left: 0;
opacity: 0;
z-index: 2;
transition: opacity 0.2s ease;
}
.video-card:hover .video-thumbnail .thumb-preview {
opacity: 1;
}
.video-card:hover .video-thumbnail .thumb-static {
opacity: 0;
}
.video-thumbnail .placeholder {
font-size: 3rem;
opacity: 0.3;
}
.duration-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.video-info {
padding: 1rem;
}
.video-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.video-meta {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--gray);
font-size: 0.85rem;
}
.uploader-info {
display: flex;
align-items: center;
gap: 0.4rem;
}
.uploader-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--gray);
}
.view-count {
display: flex;
align-items: center;
gap: 0.25rem;
}
.upload-date {
color: #666;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.no-videos {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-videos-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.load-more {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.load-more-btn {
padding: 0.75rem 2rem;
background: #8b5cf6;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.load-more-btn:hover {
background: #7c3aed;
}
</style>
<div class="container">
<div class="hero">
<h1>Video Realms</h1>
<p>Browse uploaded videos from our community</p>
</div>
{#if loading}
<div style="text-align: center; padding: 2rem;">
<p>Loading videos...</p>
</div>
{:else if videos.length === 0}
<div class="no-videos">
<div class="no-videos-icon">🎬</div>
<h2>No videos yet</h2>
<p>Be the first to upload a video!</p>
</div>
{:else}
{#each realmGroups as group}
<div class="realm-group">
<div class="realm-header">
<div class="realm-title">
<h2>{group.realmName}</h2>
<span class="realm-badge">{group.videos.length} videos</span>
</div>
<a href={`/video/realm/${group.realmId}`} class="view-realm-link">
View all
</a>
</div>
<div class="video-grid">
{#each group.videos.slice(0, 4) as video}
<a href={`/watch/${video.id}`} class="video-card">
<div class="video-thumbnail">
{#if video.thumbnailPath || video.previewPath}
<img
src={video.thumbnailPath || video.previewPath}
alt={video.title}
class="thumb-static"
/>
{#if video.previewPath}
<img
src={video.previewPath}
alt={video.title}
class="thumb-preview"
/>
{/if}
{:else}
<span class="placeholder">🎬</span>
{/if}
<span class="duration-badge">{formatDuration(video.durationSeconds)}</span>
</div>
<div class="video-info">
<h3>{video.title}</h3>
<div class="video-meta">
<div class="uploader-info">
{#if video.avatarUrl}
<img src={video.avatarUrl} alt={video.username} class="uploader-avatar" />
{:else}
<div class="uploader-avatar"></div>
{/if}
<span>{video.username}</span>
</div>
<div class="view-count">
{formatViews(video.viewCount)} views
</div>
</div>
<div class="upload-date">{timeAgo(video.createdAt)}</div>
</div>
</a>
{/each}
</div>
</div>
{/each}
{#if hasMore}
<div class="load-more">
<button class="load-more-btn" on:click={loadMore}>Load More</button>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,355 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
let realm = null;
let videos = [];
let loading = true;
let error = null;
$: realmId = $page.params.id;
function formatDuration(seconds) {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatViews(count) {
if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M';
if (count >= 1000) return (count / 1000).toFixed(1) + 'K';
return count.toString();
}
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) + ' min ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
return date.toLocaleDateString();
}
async function loadRealmVideos() {
if (!browser || !realmId) return;
try {
const res = await fetch(`/api/videos/realm/${realmId}`);
if (res.ok) {
const data = await res.json();
realm = data.realm || null;
videos = data.videos || [];
} else if (res.status === 404) {
error = 'Realm not found';
} else {
error = 'Failed to load realm';
}
} catch (e) {
console.error('Failed to load realm videos:', e);
error = 'Failed to load realm';
} finally {
loading = false;
}
}
onMount(() => {
loadRealmVideos();
});
$: if (browser && realmId) {
loading = true;
error = null;
loadRealmVideos();
}
</script>
<style>
.realm-header {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border);
}
.realm-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #8b5cf6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.realm-description {
font-size: 1.1rem;
color: var(--gray);
margin-bottom: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.realm-meta {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
color: var(--gray);
font-size: 0.9rem;
}
.realm-meta-item {
display: flex;
align-items: center;
gap: 0.4rem;
}
.owner-link {
color: #8b5cf6;
text-decoration: none;
}
.owner-link:hover {
text-decoration: underline;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #8b5cf6;
text-decoration: none;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
.video-count-badge {
background: rgba(139, 92, 246, 0.2);
color: #8b5cf6;
padding: 0.3rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.video-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.3);
border-color: #8b5cf6;
}
.video-thumbnail {
width: 100%;
aspect-ratio: 16/9;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.video-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-thumbnail .thumb-static {
position: relative;
z-index: 1;
}
.video-thumbnail .thumb-preview {
position: absolute;
top: 0;
left: 0;
opacity: 0;
z-index: 2;
transition: opacity 0.2s ease;
}
.video-card:hover .video-thumbnail .thumb-preview {
opacity: 1;
}
.video-card:hover .video-thumbnail .thumb-static {
opacity: 0;
}
.video-thumbnail .placeholder {
font-size: 3rem;
opacity: 0.3;
}
.duration-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.video-info {
padding: 1rem;
}
.video-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.video-meta {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--gray);
font-size: 0.85rem;
}
.view-count {
display: flex;
align-items: center;
gap: 0.25rem;
}
.upload-date {
color: #666;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.no-videos {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-videos-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.error-message {
text-align: center;
padding: 4rem 0;
color: #ef4444;
}
.loading {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
</style>
<div class="container">
<a href="/video" class="back-link">
<span></span> Back to all videos
</a>
{#if loading}
<div class="loading">
<p>Loading realm...</p>
</div>
{:else if error}
<div class="error-message">
<h2>{error}</h2>
<p>The realm you're looking for doesn't exist or has been removed.</p>
</div>
{:else if realm}
<div class="realm-header">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}
<div class="realm-meta">
<div class="realm-meta-item">
<span>by</span>
<a href={`/profile/${realm.username}`} class="owner-link">{realm.username}</a>
</div>
<div class="video-count-badge">
{videos.length} {videos.length === 1 ? 'video' : 'videos'}
</div>
</div>
</div>
{#if videos.length === 0}
<div class="no-videos">
<div class="no-videos-icon">📂</div>
<h2>No videos yet</h2>
<p>This realm doesn't have any videos yet.</p>
</div>
{:else}
<div class="video-grid">
{#each videos as video}
<a href={`/watch/${video.id}`} class="video-card">
<div class="video-thumbnail">
{#if video.thumbnailPath || video.previewPath}
<img
src={video.thumbnailPath || video.previewPath}
alt={video.title}
class="thumb-static"
/>
{#if video.previewPath}
<img
src={video.previewPath}
alt={video.title}
class="thumb-preview"
/>
{/if}
{:else}
<span class="placeholder">🎬</span>
{/if}
<span class="duration-badge">{formatDuration(video.durationSeconds)}</span>
</div>
<div class="video-info">
<h3>{video.title}</h3>
<div class="video-meta">
<div class="view-count">
{formatViews(video.viewCount)} views
</div>
</div>
<div class="upload-date">{timeAgo(video.createdAt)}</div>
</div>
</a>
{/each}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,377 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { siteSettings } from '$lib/stores/siteSettings';
import { formatDuration, formatViews, timeAgo } from '$lib/utils/formatters';
let videos = [];
let loading = true;
let loadingMore = false;
let page = 1;
let hasMore = true;
// Group videos by realm
$: groupedVideos = videos.reduce((acc, video) => {
const realmKey = video.realmId || 'unknown';
if (!acc[realmKey]) {
acc[realmKey] = {
realmId: video.realmId,
realmName: video.realmName || 'Unknown Realm',
videos: []
};
}
acc[realmKey].videos.push(video);
return acc;
}, {});
$: realmGroups = Object.values(groupedVideos);
async function loadVideos(append = false) {
if (!browser) return;
try {
const res = await fetch(`/api/videos?page=${page}&limit=20`);
if (res.ok) {
const data = await res.json();
const fetchedVideos = data.videos || [];
if (append) {
videos = [...videos, ...fetchedVideos];
} else {
videos = fetchedVideos;
}
hasMore = fetchedVideos.length === 20;
}
} catch (e) {
console.error('Failed to load videos:', e);
} finally {
loading = false;
}
}
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
page++;
await loadVideos(true);
loadingMore = false;
}
onMount(() => {
loadVideos();
});
</script>
<svelte:head>
<title>{$siteSettings.site_title} - Videos</title>
</svelte:head>
<style>
.hero {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
}
.hero h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #8b5cf6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.1rem;
color: var(--gray);
}
/* Realm group styles */
.realm-group {
margin-bottom: 3rem;
}
.realm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.realm-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.realm-title h2 {
margin: 0;
font-size: 1.3rem;
}
.realm-badge {
background: rgba(139, 92, 246, 0.2);
color: #8b5cf6;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.view-realm-link {
color: #8b5cf6;
text-decoration: none;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.3rem;
transition: opacity 0.2s;
}
.view-realm-link:hover {
opacity: 0.8;
text-decoration: underline;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.video-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.3);
border-color: #8b5cf6;
}
.video-thumbnail {
width: 100%;
aspect-ratio: 16/9;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.video-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-thumbnail .thumb-static {
position: relative;
z-index: 1;
}
.video-thumbnail .thumb-preview {
position: absolute;
top: 0;
left: 0;
opacity: 0;
z-index: 2;
transition: opacity 0.2s ease;
}
.video-card:hover .video-thumbnail .thumb-preview {
opacity: 1;
}
.video-card:hover .video-thumbnail .thumb-static {
opacity: 0;
}
.video-thumbnail .placeholder {
font-size: 3rem;
opacity: 0.3;
}
.duration-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.video-info {
padding: 1rem;
}
.video-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.video-meta {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--gray);
font-size: 0.85rem;
}
.uploader-info {
display: flex;
align-items: center;
gap: 0.4rem;
}
.uploader-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--gray);
}
.view-count {
display: flex;
align-items: center;
gap: 0.25rem;
}
.upload-date {
color: #666;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.no-videos {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-videos-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.load-more {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.load-more-btn {
padding: 0.75rem 2rem;
background: #8b5cf6;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.load-more-btn:hover {
background: #7c3aed;
}
</style>
<div class="container">
<div class="hero">
<h1>Video Realms</h1>
<p>Browse uploaded videos from our community</p>
</div>
{#if loading}
<div style="text-align: center; padding: 2rem;">
<p>Loading videos...</p>
</div>
{:else if videos.length === 0}
<div class="no-videos">
<div class="no-videos-icon">🎬</div>
<h2>No videos yet</h2>
<p>Be the first to upload a video!</p>
</div>
{:else}
{#each realmGroups as group}
<div class="realm-group">
<div class="realm-header">
<div class="realm-title">
<h2>{group.realmName}</h2>
<span class="realm-badge">{group.videos.length} videos</span>
</div>
<a href={`/videos/realm/${group.realmId}`} class="view-realm-link">
View all
</a>
</div>
<div class="video-grid">
{#each group.videos.slice(0, 4) as video}
<a href={`/watch/${video.id}`} class="video-card">
<div class="video-thumbnail">
{#if video.thumbnailPath || video.previewPath}
<img
src={video.thumbnailPath || video.previewPath}
alt={video.title}
class="thumb-static"
/>
{#if video.previewPath}
<img
src={video.previewPath}
alt={video.title}
class="thumb-preview"
/>
{/if}
{:else}
<span class="placeholder">🎬</span>
{/if}
<span class="duration-badge">{formatDuration(video.durationSeconds)}</span>
</div>
<div class="video-info">
<h3>{video.title}</h3>
<div class="video-meta">
<div class="uploader-info">
{#if video.avatarUrl}
<img src={video.avatarUrl} alt={video.username} class="uploader-avatar" />
{:else}
<div class="uploader-avatar"></div>
{/if}
<span>{video.username}</span>
</div>
<div class="view-count">
{formatViews(video.viewCount)} views
</div>
</div>
<div class="upload-date">{timeAgo(video.createdAt)}</div>
</div>
</a>
{/each}
</div>
</div>
{/each}
{#if hasMore}
<div class="load-more">
<button class="load-more-btn" on:click={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading...' : 'Load More'}
</button>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,329 @@
<script>
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { formatDuration, formatViews, timeAgo } from '$lib/utils/formatters';
let realm = null;
let videos = [];
let loading = true;
let error = null;
$: realmId = $page.params.id;
async function loadRealmVideos() {
if (!browser || !realmId) return;
try {
const res = await fetch(`/api/videos/realm/${realmId}`);
if (res.ok) {
const data = await res.json();
realm = data.realm || null;
videos = data.videos || [];
} else if (res.status === 404) {
error = 'Realm not found';
} else {
error = 'Failed to load realm';
}
} catch (e) {
console.error('Failed to load realm videos:', e);
error = 'Failed to load realm';
} finally {
loading = false;
}
}
let lastRealmId = null;
$: if (browser && realmId && realmId !== lastRealmId) {
lastRealmId = realmId;
loading = true;
error = null;
loadRealmVideos();
}
</script>
<style>
.realm-header {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border);
}
.realm-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #8b5cf6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.realm-description {
font-size: 1.1rem;
color: var(--gray);
margin-bottom: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.realm-meta {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
color: var(--gray);
font-size: 0.9rem;
}
.realm-meta-item {
display: flex;
align-items: center;
gap: 0.4rem;
}
.owner-link {
color: #8b5cf6;
text-decoration: none;
}
.owner-link:hover {
text-decoration: underline;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #8b5cf6;
text-decoration: none;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
.video-count-badge {
background: rgba(139, 92, 246, 0.2);
color: #8b5cf6;
padding: 0.3rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.video-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.3);
border-color: #8b5cf6;
}
.video-thumbnail {
width: 100%;
aspect-ratio: 16/9;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.video-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-thumbnail .thumb-static {
position: relative;
z-index: 1;
}
.video-thumbnail .thumb-preview {
position: absolute;
top: 0;
left: 0;
opacity: 0;
z-index: 2;
transition: opacity 0.2s ease;
}
.video-card:hover .video-thumbnail .thumb-preview {
opacity: 1;
}
.video-card:hover .video-thumbnail .thumb-static {
opacity: 0;
}
.video-thumbnail .placeholder {
font-size: 3rem;
opacity: 0.3;
}
.duration-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.video-info {
padding: 1rem;
}
.video-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.video-meta {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--gray);
font-size: 0.85rem;
}
.view-count {
display: flex;
align-items: center;
gap: 0.25rem;
}
.upload-date {
color: #666;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.no-videos {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-videos-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.error-message {
text-align: center;
padding: 4rem 0;
color: #ef4444;
}
.loading {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
</style>
<div class="container">
<a href="/videos" class="back-link">
<span></span> Back to all videos
</a>
{#if loading}
<div class="loading">
<p>Loading realm...</p>
</div>
{:else if error}
<div class="error-message">
<h2>{error}</h2>
<p>The realm you're looking for doesn't exist or has been removed.</p>
</div>
{:else if realm}
<div class="realm-header">
<h1>{realm.name}</h1>
{#if realm.description}
<p class="realm-description">{realm.description}</p>
{/if}
<div class="realm-meta">
<div class="realm-meta-item">
<span>by</span>
<a href={`/profile/${realm.username}`} class="owner-link">{realm.username}</a>
</div>
<div class="video-count-badge">
{videos.length} {videos.length === 1 ? 'video' : 'videos'}
</div>
</div>
</div>
{#if videos.length === 0}
<div class="no-videos">
<div class="no-videos-icon">📂</div>
<h2>No videos yet</h2>
<p>This realm doesn't have any videos yet.</p>
</div>
{:else}
<div class="video-grid">
{#each videos as video}
<a href={`/watch/${video.id}`} class="video-card">
<div class="video-thumbnail">
{#if video.thumbnailPath || video.previewPath}
<img
src={video.thumbnailPath || video.previewPath}
alt={video.title}
class="thumb-static"
/>
{#if video.previewPath}
<img
src={video.previewPath}
alt={video.title}
class="thumb-preview"
/>
{/if}
{:else}
<span class="placeholder">🎬</span>
{/if}
<span class="duration-badge">{formatDuration(video.durationSeconds)}</span>
</div>
<div class="video-info">
<h3>{video.title}</h3>
<div class="video-meta">
<div class="view-count">
{formatViews(video.viewCount)} views
</div>
</div>
<div class="upload-date">{timeAgo(video.createdAt)}</div>
</div>
</a>
{/each}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,298 @@
<script>
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { siteSettings } from '$lib/stores/siteSettings';
import { formatDuration, formatViews, formatBytes, formatBitrate, formatCodec, formatDate } from '$lib/utils/formatters';
let video = null;
let loading = true;
let error = null;
let videoElement;
$: videoId = $page.params.id;
async function loadVideo() {
if (!browser) return;
try {
const res = await fetch(`/api/videos/${videoId}`);
if (res.ok) {
const data = await res.json();
video = data.video;
// Increment view count
fetch(`/api/videos/${videoId}/view`, { method: 'POST' });
} else if (res.status === 404) {
error = 'Video not found';
} else {
error = 'Failed to load video';
}
} catch (e) {
console.error('Failed to load video:', e);
error = 'Failed to load video';
} finally {
loading = false;
}
}
let lastVideoId = null;
$: if (browser && videoId && videoId !== lastVideoId) {
lastVideoId = videoId;
loading = true;
error = null;
video = null;
loadVideo();
}
</script>
<svelte:head>
<title>{video ? `${$siteSettings.site_title} - ${video.title}` : $siteSettings.site_title}</title>
</svelte:head>
<style>
.watch-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.video-player-wrapper {
background: #000;
border-radius: 12px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.video-player {
width: 100%;
aspect-ratio: 16/9;
background: #000;
}
.video-details {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.video-title {
font-size: 1.5rem;
margin: 0 0 1rem 0;
line-height: 1.3;
}
.video-stats {
display: flex;
align-items: center;
gap: 1rem;
color: var(--gray);
font-size: 0.9rem;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.uploader-section {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.uploader-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--gray);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: white;
}
.uploader-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.uploader-info h4 {
margin: 0 0 0.25rem 0;
font-size: 1.1rem;
}
.uploader-info a {
color: var(--primary);
text-decoration: none;
}
.uploader-info a:hover {
text-decoration: underline;
}
.uploader-info span {
color: var(--gray);
font-size: 0.85rem;
}
.video-description {
background: #0a0a0a;
border-radius: 8px;
padding: 1rem;
color: #ccc;
line-height: 1.6;
white-space: pre-wrap;
}
.video-description.empty {
color: var(--gray);
font-style: italic;
}
.video-metadata {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
font-size: 0.85rem;
color: var(--gray);
}
.metadata-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.metadata-item label {
font-weight: 600;
color: #888;
}
.loading, .error {
text-align: center;
padding: 4rem 0;
}
.error {
color: #ff6b6b;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--gray);
text-decoration: none;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.back-link:hover {
color: var(--primary);
}
</style>
<div class="watch-container">
<a href="/videos" class="back-link">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
</svg>
Back to Videos
</a>
{#if loading}
<div class="loading">
<p>Loading video...</p>
</div>
{:else if error}
<div class="error">
<h2>{error}</h2>
<p>The video you're looking for might have been removed or made private.</p>
</div>
{:else if video}
<div class="video-player-wrapper">
<video
bind:this={videoElement}
class="video-player"
src={video.filePath}
controls
autoplay
playsinline
>
<track kind="captions" />
</video>
</div>
<div class="video-details">
<h1 class="video-title">{video.title}</h1>
<div class="video-stats">
<span>{formatViews(video.viewCount, true)}</span>
<span></span>
<span>Uploaded {formatDate(video.createdAt)}</span>
</div>
<div class="uploader-section">
<div class="uploader-avatar">
{#if video.avatarUrl}
<img src={video.avatarUrl} alt={video.username} />
{:else}
{video.username.charAt(0).toUpperCase()}
{/if}
</div>
<div class="uploader-info">
<h4><a href={`/profile/${video.username}`}>{video.username}</a></h4>
</div>
</div>
<div class="video-description" class:empty={!video.description}>
{video.description || 'No description provided.'}
</div>
<div class="video-metadata">
{#if video.durationSeconds}
<div class="metadata-item">
<label>Duration</label>
<span>{formatDuration(video.durationSeconds)}</span>
</div>
{/if}
{#if video.fileSizeBytes}
<div class="metadata-item">
<label>File Size</label>
<span>{formatBytes(video.fileSizeBytes)}</span>
</div>
{/if}
{#if video.width && video.height}
<div class="metadata-item">
<label>Resolution</label>
<span>{video.width}x{video.height}</span>
</div>
{/if}
{#if video.bitrate}
<div class="metadata-item">
<label>Bitrate</label>
<span>{formatBitrate(video.bitrate)}</span>
</div>
{/if}
{#if video.videoCodec}
<div class="metadata-item">
<label>Video Codec</label>
<span>{formatCodec(video.videoCodec)}</span>
</div>
{/if}
{#if video.audioCodec}
<div class="metadata-item">
<label>Audio Codec</label>
<span>{formatCodec(video.audioCodec)}</span>
</div>
{/if}
</div>
</div>
{/if}
</div>