Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
|
|
@ -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
513
frontend/src/routes/[realm]/watch/+page.svelte
Normal file
513
frontend/src/routes/[realm]/watch/+page.svelte
Normal 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
440
frontend/src/routes/audio/+page.svelte
Normal file
440
frontend/src/routes/audio/+page.svelte
Normal 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>
|
||||
492
frontend/src/routes/audio/[name]/+page.svelte
Normal file
492
frontend/src/routes/audio/[name]/+page.svelte
Normal 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>←</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>
|
||||
492
frontend/src/routes/audio/realm/[id]/+page.svelte
Normal file
492
frontend/src/routes/audio/realm/[id]/+page.svelte
Normal 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>←</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>
|
||||
28
frontend/src/routes/chat/popout/+layout@.svelte
Normal file
28
frontend/src/routes/chat/popout/+layout@.svelte
Normal 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 />
|
||||
67
frontend/src/routes/chat/popout/+page.svelte
Normal file
67
frontend/src/routes/chat/popout/+page.svelte
Normal 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>
|
||||
28
frontend/src/routes/chat/terminal/+layout@.svelte
Normal file
28
frontend/src/routes/chat/terminal/+layout@.svelte
Normal 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 />
|
||||
564
frontend/src/routes/chat/terminal/+page.svelte
Normal file
564
frontend/src/routes/chat/terminal/+page.svelte
Normal 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>
|
||||
481
frontend/src/routes/ebooks/+page.svelte
Normal file
481
frontend/src/routes/ebooks/+page.svelte
Normal 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>
|
||||
428
frontend/src/routes/ebooks/realm/[id]/+page.svelte
Normal file
428
frontend/src/routes/ebooks/realm/[id]/+page.svelte
Normal 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>←</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>
|
||||
1030
frontend/src/routes/forums/+page.svelte
Normal file
1030
frontend/src/routes/forums/+page.svelte
Normal file
File diff suppressed because it is too large
Load diff
1411
frontend/src/routes/forums/[slug]/+page.svelte
Normal file
1411
frontend/src/routes/forums/[slug]/+page.svelte
Normal file
File diff suppressed because it is too large
Load diff
771
frontend/src/routes/forums/[slug]/thread/[threadId]/+page.svelte
Normal file
771
frontend/src/routes/forums/[slug]/thread/[threadId]/+page.svelte
Normal 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><<(.+?)<\/p>/gs, '<p class="redtext"><<$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}
|
||||
205
frontend/src/routes/games/+page.svelte
Normal file
205
frontend/src/routes/games/+page.svelte
Normal 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">♞</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>
|
||||
891
frontend/src/routes/games/chess/+page.svelte
Normal file
891
frontend/src/routes/games/chess/+page.svelte
Normal 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">← 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>
|
||||
|
|
@ -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
|
|
@ -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}
|
||||
424
frontend/src/routes/read/[id]/+page.svelte
Normal file
424
frontend/src/routes/read/[id]/+page.svelte
Normal 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>←</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">‹</button>
|
||||
|
||||
<div bind:this={readerContainer} class="reader-container"></div>
|
||||
|
||||
<button class="nav-btn nav-next" on:click={nextPage} title="Next page">›</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 ← → arrow keys to navigate
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load diff
225
frontend/src/routes/stats/+page.svelte
Normal file
225
frontend/src/routes/stats/+page.svelte
Normal 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>
|
||||
371
frontend/src/routes/video/+page.svelte
Normal file
371
frontend/src/routes/video/+page.svelte
Normal 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>
|
||||
355
frontend/src/routes/video/realm/[id]/+page.svelte
Normal file
355
frontend/src/routes/video/realm/[id]/+page.svelte
Normal 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>
|
||||
377
frontend/src/routes/videos/+page.svelte
Normal file
377
frontend/src/routes/videos/+page.svelte
Normal 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>
|
||||
329
frontend/src/routes/videos/realm/[id]/+page.svelte
Normal file
329
frontend/src/routes/videos/realm/[id]/+page.svelte
Normal 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>
|
||||
298
frontend/src/routes/watch/[id]/+page.svelte
Normal file
298
frontend/src/routes/watch/[id]/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue