This commit is contained in:
parent
2cf84704da
commit
a206a606f7
12 changed files with 231 additions and 111 deletions
|
|
@ -824,7 +824,7 @@ void RealmController::getLiveRealms(const HttpRequestPtr &,
|
|||
auto dbClient = app().getDbClient();
|
||||
// SECURITY: Do NOT expose stream_key in public API - it allows stream hijacking
|
||||
*dbClient << "SELECT r.name, r.viewer_count, r.viewer_multiplier, r.offline_image_url, r.title_color, "
|
||||
"u.username, u.avatar_url "
|
||||
"u.username, u.avatar_url, u.user_color "
|
||||
"FROM realms r JOIN users u ON r.user_id = u.id "
|
||||
"WHERE r.is_live = true AND r.is_active = true "
|
||||
"ORDER BY (r.viewer_count * COALESCE(r.viewer_multiplier, 1)) DESC"
|
||||
|
|
@ -850,6 +850,7 @@ void RealmController::getLiveRealms(const HttpRequestPtr &,
|
|||
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||
realm["offlineImageUrl"] = row["offline_image_url"].isNull() ? "" : row["offline_image_url"].as<std::string>();
|
||||
realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>();
|
||||
realm["colorCode"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
|
||||
resp.append(realm);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,7 @@
|
|||
import { refreshAccessToken } from './stores/auth.js';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api';
|
||||
|
||||
// Track if we're currently refreshing to avoid multiple simultaneous refreshes
|
||||
let isRefreshing = false;
|
||||
let refreshPromise = null;
|
||||
|
||||
// Attempt to refresh the access token
|
||||
async function attemptTokenRefresh() {
|
||||
if (isRefreshing) {
|
||||
// Wait for the existing refresh to complete
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
}).then(response => {
|
||||
isRefreshing = false;
|
||||
return response.ok;
|
||||
}).catch(() => {
|
||||
isRefreshing = false;
|
||||
return false;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function fetchAPI(endpoint, options = {}, retryAfterRefresh = true) {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
|
|
@ -37,8 +13,9 @@ async function fetchAPI(endpoint, options = {}, retryAfterRefresh = true) {
|
|||
});
|
||||
|
||||
// If we get a 401 and haven't already retried, attempt token refresh
|
||||
// Uses centralized refresh from auth.js (includes retry logic)
|
||||
if (response.status === 401 && retryAfterRefresh) {
|
||||
const refreshed = await attemptTokenRefresh();
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
// Retry the original request (but don't retry again if it fails)
|
||||
return fetchAPI(endpoint, options, false);
|
||||
|
|
|
|||
|
|
@ -451,8 +451,9 @@ class ChatWebSocket {
|
|||
/**
|
||||
* Manually trigger reconnection - resets attempt counter and tries immediately
|
||||
* Also handles authentication on existing connection (e.g., when user logs in while connected as guest)
|
||||
* @param {string} [token] - Optional token to use for authentication. If not provided, will attempt to refresh.
|
||||
*/
|
||||
async manualReconnect() {
|
||||
async manualReconnect(token = null) {
|
||||
console.log('Manual reconnect triggered');
|
||||
if (this.reconnectResetTimer) {
|
||||
clearTimeout(this.reconnectResetTimer);
|
||||
|
|
@ -460,9 +461,25 @@ class ChatWebSocket {
|
|||
}
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Get fresh token from localStorage (may have been set by login)
|
||||
const freshToken =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
// Get fresh token - use provided token, or try to refresh via cookie-based auth
|
||||
let freshToken = token;
|
||||
if (!freshToken) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
freshToken = data.token;
|
||||
console.log('[ChatWebSocket] Got fresh token from refresh endpoint');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ChatWebSocket] Could not refresh token:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a token and connection is open, just send auth message
|
||||
// This handles the case where user logs in while already connected as guest
|
||||
|
|
|
|||
|
|
@ -408,7 +408,6 @@
|
|||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0; /* Prevent input from shrinking */
|
||||
}
|
||||
|
|
@ -419,13 +418,12 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
border-color: var(--accent-blue);
|
||||
outline: 1px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.input-icons {
|
||||
|
|
|
|||
|
|
@ -217,10 +217,11 @@
|
|||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatEpoch(timestamp) {
|
||||
const epoch = new Date(timestamp).getTime();
|
||||
// Get last 8 digits of epoch for maximum randomness
|
||||
const last8 = String(epoch).slice(-8);
|
||||
function formatEpoch() {
|
||||
// Use high-resolution time for microsecond precision (1000x more granular than milliseconds)
|
||||
const highResTime = performance.timeOrigin + performance.now();
|
||||
const microEpoch = Math.floor(highResTime * 1000);
|
||||
const last8 = String(microEpoch).slice(-8);
|
||||
return `No.${last8}`;
|
||||
}
|
||||
|
||||
|
|
@ -409,7 +410,7 @@
|
|||
</button>
|
||||
{/if}
|
||||
<span class="timestamp">{formatTime(message.timestamp)}</span>
|
||||
<span class="epoch">{formatEpoch(message.timestamp)}</span>
|
||||
<span class="epoch">{formatEpoch()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -431,7 +432,7 @@
|
|||
</button>
|
||||
{/if}
|
||||
<span class="timestamp">{formatTime(message.timestamp)}</span>
|
||||
<span class="epoch">{formatEpoch(message.timestamp)}</span>
|
||||
<span class="epoch">{formatEpoch()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -649,7 +650,7 @@
|
|||
|
||||
.epoch {
|
||||
color: var(--text-faint);
|
||||
font-size: 0.6rem;
|
||||
font-size: 0.65rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -719,7 +719,6 @@
|
|||
max-height: 100%;
|
||||
min-height: 0;
|
||||
background: #1a1a1a;
|
||||
border-left: 1px solid #333;
|
||||
position: relative;
|
||||
overflow: hidden; /* Prevent panel itself from scrolling */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@
|
|||
$: liveRealms = allRealms.filter(r => r.isLive);
|
||||
$: offlineRealms = allRealms.filter(r => !r.isLive);
|
||||
|
||||
// Get the current realm from URL if on a live page (e.g., /realmname/live)
|
||||
// Get the current realm from URL if on a realm page (e.g., /realmname/live or /realmname/watch)
|
||||
$: currentLiveRealm = (() => {
|
||||
const path = $page.url.pathname;
|
||||
const match = path.match(/^\/([^/]+)\/live$/);
|
||||
const match = path.match(/^\/([^/]+)\/(live|watch)$/);
|
||||
return match ? match[1] : null;
|
||||
})();
|
||||
|
||||
|
|
@ -138,10 +138,11 @@
|
|||
</a>
|
||||
<button
|
||||
class="tile-btn"
|
||||
class:tiled={isTiled && !isViewing}
|
||||
class:remove={isTiled && !isViewing}
|
||||
class:viewing={isViewing}
|
||||
on:click|stopPropagation={() => toggleTile(stream, isTiled, isViewing)}
|
||||
title={isViewing ? 'Currently viewing' : isTiled ? 'Remove from tiles' : 'Add to tiles'}
|
||||
title={isViewing ? 'Currently viewing this realm' : isTiled ? 'Remove from tiles' : 'Add to tiles'}
|
||||
disabled={isViewing}
|
||||
>{isViewing ? '*' : isTiled ? '−' : '+'}</button>
|
||||
</div>
|
||||
|
|
@ -175,10 +176,11 @@
|
|||
</a>
|
||||
<button
|
||||
class="tile-btn"
|
||||
class:tiled={isTiled && !isViewing}
|
||||
class:remove={isTiled && !isViewing}
|
||||
class:viewing={isViewing}
|
||||
on:click|stopPropagation={() => toggleTile(stream, isTiled, isViewing)}
|
||||
title={isViewing ? 'Currently viewing' : isTiled ? 'Remove from tiles' : 'Add to tiles'}
|
||||
title={isViewing ? 'Currently viewing this realm' : isTiled ? 'Remove from tiles' : 'Add to tiles'}
|
||||
disabled={isViewing}
|
||||
>{isViewing ? '*' : isTiled ? '−' : '+'}</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<script>
|
||||
import { watchSync, isPlaying, canControl, currentVideo } from '$lib/stores/watchSync';
|
||||
import { watchSync, isPlaying, canControl, currentVideo, viewerCount } from '$lib/stores/watchSync';
|
||||
|
||||
export let currentTime = 0;
|
||||
export let duration = 0;
|
||||
export let realmName = '';
|
||||
export let username = '';
|
||||
export let titleColor = '#ffffff';
|
||||
export let colorCode = '#561D5E';
|
||||
export let avatarUrl = '';
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
|
|
@ -54,9 +59,7 @@
|
|||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
|
|
@ -137,11 +140,75 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.no-control-hint {
|
||||
.room-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.room-info-row > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.owner-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.owner-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray);
|
||||
color: var(--white);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.owner-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.owner-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.owner-name:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.room-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--white);
|
||||
text-align: center;
|
||||
margin-top: 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.viewer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.viewer-badge svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -213,9 +280,23 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if !$canControl}
|
||||
<div class="no-control-hint">
|
||||
Only designated users can control playback
|
||||
<div class="room-info-row">
|
||||
<div class="owner-info">
|
||||
<div class="owner-avatar" style="background: {colorCode}; border-color: {colorCode}">
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt={username} />
|
||||
{:else}
|
||||
{username?.charAt(0).toUpperCase() || '?'}
|
||||
{/if}
|
||||
</div>
|
||||
<a href="/profile/{username}" class="owner-name" style="color: {colorCode}">{username}</a>
|
||||
</div>
|
||||
{/if}
|
||||
<h2 class="room-title" style="color: {titleColor}">{realmName}</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ import { goto } from '$app/navigation';
|
|||
|
||||
// Token refresh interval (2 hours - before the 2.5-hour access token expires)
|
||||
const REFRESH_INTERVAL_MS = 2 * 60 * 60 * 1000;
|
||||
// Retry configuration for token refresh
|
||||
const MAX_REFRESH_RETRIES = 3;
|
||||
const INITIAL_RETRY_DELAY_MS = 1000; // 1 second
|
||||
let refreshInterval = null;
|
||||
let isRefreshing = false;
|
||||
let refreshPromise = null;
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
// Silent token refresh function
|
||||
async function refreshAccessToken() {
|
||||
if (isRefreshing) return false;
|
||||
isRefreshing = true;
|
||||
|
||||
// Silent token refresh function with retry logic
|
||||
// Returns: { success: boolean, isAuthError: boolean }
|
||||
async function attemptSingleRefresh() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
|
|
@ -24,21 +27,65 @@ async function refreshAccessToken() {
|
|||
if (data.user) {
|
||||
auth.updateUser(data.user);
|
||||
}
|
||||
isRefreshing = false;
|
||||
return true;
|
||||
return { success: true, isAuthError: false };
|
||||
} else {
|
||||
// Refresh failed - session expired
|
||||
console.log('Token refresh failed - session expired');
|
||||
isRefreshing = false;
|
||||
return false;
|
||||
// 401/403 = auth error (token truly invalid/expired)
|
||||
// 5xx = server error (should retry)
|
||||
const isAuthError = response.status === 401 || response.status === 403;
|
||||
console.log(`Token refresh failed with status ${response.status}`);
|
||||
return { success: false, isAuthError };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
isRefreshing = false;
|
||||
return false;
|
||||
// Network error - should retry
|
||||
console.error('Token refresh network error:', error);
|
||||
return { success: false, isAuthError: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh with retry logic - exported for use by other modules (api.js)
|
||||
export async function refreshAccessToken() {
|
||||
// If already refreshing, wait for the existing refresh to complete
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
let lastResult = { success: false, isAuthError: false };
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_REFRESH_RETRIES; attempt++) {
|
||||
lastResult = await attemptSingleRefresh();
|
||||
|
||||
if (lastResult.success) {
|
||||
consecutiveFailures = 0;
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's an auth error (401/403), don't retry - token is invalid
|
||||
if (lastResult.isAuthError) {
|
||||
console.log('Auth error - token is invalid, not retrying');
|
||||
break;
|
||||
}
|
||||
|
||||
// Network/server error - retry with exponential backoff
|
||||
if (attempt < MAX_REFRESH_RETRIES) {
|
||||
const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
|
||||
console.log(`Retrying token refresh in ${delay}ms (attempt ${attempt}/${MAX_REFRESH_RETRIES})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted or auth error
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
return false;
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// Start automatic token refresh
|
||||
function startTokenRefresh() {
|
||||
if (!browser) return;
|
||||
|
|
@ -47,14 +94,22 @@ function startTokenRefresh() {
|
|||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
consecutiveFailures = 0;
|
||||
|
||||
// Refresh every 12 minutes
|
||||
// Refresh every 2 hours (before the 2.5-hour access token expires)
|
||||
refreshInterval = setInterval(async () => {
|
||||
const success = await refreshAccessToken();
|
||||
if (!success) {
|
||||
// Stop refresh interval and logout
|
||||
stopTokenRefresh();
|
||||
auth.logout();
|
||||
consecutiveFailures++;
|
||||
// Only logout after 2 consecutive interval failures
|
||||
// This gives more resilience against temporary network issues
|
||||
if (consecutiveFailures >= 2) {
|
||||
console.log('Multiple consecutive refresh failures - logging out');
|
||||
stopTokenRefresh();
|
||||
auth.logout();
|
||||
} else {
|
||||
console.log(`Refresh failed (${consecutiveFailures}/2 consecutive failures) - will retry next interval`);
|
||||
}
|
||||
}
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,8 +96,7 @@
|
|||
}
|
||||
|
||||
.nav {
|
||||
background: #111;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #000;
|
||||
padding: var(--nav-padding-y) 0;
|
||||
margin-bottom: var(--nav-margin-bottom);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1024,7 +1024,7 @@
|
|||
{:else}
|
||||
<div class="streamer-avatar"></div>
|
||||
{/if}
|
||||
<span>{stream.username}</span>
|
||||
<span style="color: {stream.colorCode}">{stream.username}</span>
|
||||
</div>
|
||||
<div class="viewer-count">
|
||||
{stream.viewerCount} {stream.viewerCount === 1 ? 'viewer' : 'viewers'}
|
||||
|
|
|
|||
|
|
@ -191,7 +191,18 @@
|
|||
// Prevent duplicate skip calls
|
||||
if (skipInProgress) return;
|
||||
|
||||
// When a video ends, skip to the next one
|
||||
// Check if current video is locked (looped) - if so, let the server handle the restart
|
||||
// The server will send a 'locked_restart' event to loop the video
|
||||
const currentVid = $currentVideo;
|
||||
if (currentVid?.isLocked) {
|
||||
// Locked video - request sync to get the restart state from server
|
||||
setTimeout(() => {
|
||||
watchSync.requestSync();
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// When a video ends, skip to the next one (only for non-locked videos)
|
||||
if ($canControl) {
|
||||
skipInProgress = true;
|
||||
watchSync.skip();
|
||||
|
|
@ -488,41 +499,20 @@
|
|||
<PlaybackControls
|
||||
{currentTime}
|
||||
duration={$currentVideo?.durationSeconds || duration}
|
||||
realmName={realm.name}
|
||||
username={realm.username}
|
||||
titleColor={realm.titleColor}
|
||||
colorCode={realm.colorCode}
|
||||
avatarUrl={realm.avatarUrl}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{#if realm.description}
|
||||
<div class="room-info-section">
|
||||
<div class="room-description">
|
||||
{realm.description}
|
||||
</div>
|
||||
|
||||
{#if realm.description}
|
||||
<div class="room-description">
|
||||
{realm.description}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue