rspace-online/modules/rtube/demo.ts

302 lines
9.1 KiB
TypeScript

/**
* rTube demo page — server-rendered HTML body.
*
* Video library with sidebar, search, player area, download/copy-link buttons.
* Client-side tube-demo.ts handles selection, filtering, and playback.
*/
/* ─── Seed Data ─────────────────────────────────────────────── */
interface DemoVideo {
name: string;
size: number;
}
const DEMO_VIDEOS: DemoVideo[] = [
{ name: "lac-blanc-test-footage.mp4", size: 245_000_000 },
{ name: "chamonix-arrival-timelapse.mp4", size: 128_000_000 },
{ name: "matterhorn-sunset-4k.mp4", size: 512_000_000 },
{ name: "paragliding-zermatt.webm", size: 89_000_000 },
{ name: "glacier-paradise-walk.mp4", size: 340_000_000 },
{ name: "tre-cime-circuit-gopro.mp4", size: 420_000_000 },
{ name: "dolomites-drone-footage.mkv", size: 780_000_000 },
{ name: "group-dinner-recap.mp4", size: 67_000_000 },
];
/* ─── Helpers ──────────────────────────────────────────────── */
function formatSize(bytes: number): string {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let i = 0;
let b = bytes;
while (b >= 1024 && i < units.length - 1) {
b /= 1024;
i++;
}
return `${b.toFixed(1)} ${units[i]}`;
}
function getIcon(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() || "";
if (["mp4", "webm", "mov"].includes(ext)) return "\u{1F3AC}";
if (["mkv", "avi"].includes(ext)) return "\u26A0\uFE0F";
return "\u{1F4C4}";
}
function isPlayable(filename: string): boolean {
const ext = filename.split(".").pop()?.toLowerCase() || "";
return ["mp4", "webm", "mov", "ogg", "m4v"].includes(ext);
}
/* ─── Render ─────────────────────────────────────────────── */
export function renderDemo(): string {
const videoListHTML = DEMO_VIDEOS.map(
(v) => `
<li class="rd-tube-item${!isPlayable(v.name) ? " rd-tube-item--unplayable" : ""}"
data-video="${v.name}" role="button" tabindex="0">
<span class="rd-tube-item__icon">${getIcon(v.name)}</span>
<span class="rd-tube-item__name" title="${v.name}">${v.name}</span>
<span class="rd-tube-item__size">${formatSize(v.size)}</span>
</li>`,
).join("\n");
return `
<div class="rd-root" style="--rd-accent-from:#ef4444; --rd-accent-to:#ec4899">
<!-- ── Hero ── -->
<section class="rd-hero">
<h1>Video Library</h1>
<p class="rd-subtitle">Browse, preview, and download videos from the Alpine Explorer expedition</p>
<div class="rd-meta">
<span>\u{1F3AC} ${DEMO_VIDEOS.length} videos</span>
<span style="color:#475569">|</span>
<span>\u{1F4BE} ${formatSize(DEMO_VIDEOS.reduce((sum, v) => sum + v.size, 0))} total</span>
<span style="color:#475569">|</span>
<span>\u{1F3D4} Alpine Explorer 2026</span>
</div>
</section>
<!-- ── Two-column layout ── -->
<div class="rd-section">
<div class="rd-tube-layout">
<!-- Sidebar -->
<aside class="rd-tube-sidebar" id="rd-video-sidebar">
<input type="text" id="rd-video-search" class="rd-tube-search"
placeholder="Search videos..." autocomplete="off" />
<h2 class="rd-tube-sidebar__heading">Library</h2>
<ul class="rd-tube-list" id="rd-video-list">
${videoListHTML}
</ul>
<p class="rd-tube-empty" id="rd-video-empty" style="display:none">No videos found</p>
</aside>
<!-- Player area -->
<div class="rd-tube-player-wrap" id="rd-video-player-area">
<div class="rd-tube-player" id="rd-player-container">
<p class="rd-tube-placeholder" id="rd-player-placeholder">Select a video to play</p>
</div>
<!-- Info bar (hidden until a video is selected) -->
<div class="rd-tube-info" id="rd-video-info" style="display:none">
<p class="rd-tube-info__name" id="rd-video-name"></p>
<div class="rd-tube-info__actions">
<a id="rd-download-btn" href="#" download class="rd-btn rd-btn--ghost rd-btn--sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</a>
<button id="rd-copy-link-btn" class="rd-btn rd-btn--ghost rd-btn--sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Copy Link
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ── CTA ── -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Host Your Video Library</h2>
<p>
rTube gives your community a private video hosting solution with streaming,
uploads, and live broadcasting — all powered by Cloudflare R2.
</p>
<a href="/create-space" style="background:linear-gradient(135deg,#ef4444,#ec4899); box-shadow:0 8px 24px rgba(239,68,68,0.25);">
Create Your Space
</a>
</div>
</section>
</div>
<style>
/* ── Tube demo layout ── */
.rd-tube-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 768px) {
.rd-tube-layout {
grid-template-columns: 1fr;
}
}
/* ── Sidebar ── */
.rd-tube-sidebar {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(51, 65, 85, 0.5);
border-radius: 1rem;
padding: 1rem;
max-height: 80vh;
overflow-y: auto;
}
.rd-tube-search {
width: 100%;
padding: 0.625rem 0.875rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.875rem;
margin-bottom: 0.75rem;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.rd-tube-search::placeholder { color: #64748b; }
.rd-tube-search:focus { border-color: #ef4444; }
.rd-tube-sidebar__heading {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin: 0 0 0.5rem;
font-weight: 600;
}
/* ── Video list ── */
.rd-tube-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.rd-tube-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.15s;
font-size: 0.875rem;
border-left: 2px solid transparent;
}
.rd-tube-item:hover { background: rgba(51, 65, 85, 0.5); }
.rd-tube-item--active {
background: rgba(239, 68, 68, 0.15);
border-left-color: #ef4444;
}
.rd-tube-item--unplayable { opacity: 0.6; }
.rd-tube-item__icon { flex-shrink: 0; font-size: 1rem; }
.rd-tube-item__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #e2e8f0;
}
.rd-tube-item__size {
font-size: 0.7rem;
color: #475569;
flex-shrink: 0;
}
.rd-tube-empty {
color: #64748b;
font-size: 0.875rem;
padding: 1rem;
text-align: center;
}
/* ── Player area ── */
.rd-tube-player-wrap { min-width: 0; }
.rd-tube-player {
background: #000;
border-radius: 1rem;
overflow: hidden;
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
}
.rd-tube-player video {
width: 100%;
height: 100%;
object-fit: contain;
}
.rd-tube-placeholder {
color: #475569;
font-size: 1.125rem;
margin: 0;
}
.rd-tube-warning {
text-align: center;
padding: 2rem;
color: #e2e8f0;
}
.rd-tube-warning__icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
.rd-tube-warning__ext { font-weight: 700; }
.rd-tube-warning__hint {
font-size: 0.875rem;
color: #64748b;
margin-top: 0.5rem;
}
/* ── Info bar ── */
.rd-tube-info {
margin-top: 0.75rem;
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(51, 65, 85, 0.5);
border-radius: 0.75rem;
padding: 0.875rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.rd-tube-info__name {
font-weight: 500;
color: #f1f5f9;
margin: 0;
font-size: 0.9375rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rd-tube-info__actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
/* ── Button size variant ── */
.rd-btn--sm {
font-size: 0.8125rem;
padding: 0.375rem 0.75rem;
gap: 0.375rem;
}
</style>`;
}