<?php
require_once 'lib/markdown.php';
$site = json_decode(file_get_contents('site.json'), true);
$manifest = file_exists('manifest.json')
? json_decode(file_get_contents('manifest.json'), true)
: ['collections' => [], 'items' => []];
$admin = file_exists('admin.json')
? json_decode(file_get_contents('admin.json'), true)
: ['order' => [], 'home' => []];
// Apply admin ordering to manifest items
foreach ($manifest['items'] as &$item) {
$coll = $item['collection'];
$name_key = $item['name'];
if (isset($admin['order'][$coll][$name_key])) {
$item['_order'] = $admin['order'][$coll][$name_key];
} else {
$item['_order'] = 99999;
}
}
unset($item);
// Sort items: within each collection, ordered first then alphabetical
usort($manifest['items'], function($a, $b) {
if ($a['collection'] !== $b['collection']) return 0;
if ($a['_order'] !== $b['_order']) return $a['_order'] - $b['_order'];
return strcmp($a['name'], $b['name']);
});
// Build home virtual collection from flagged items
$home_items = [];
foreach ($admin['home'] as $key => $priority) {
$parts = explode('/', $key, 2);
if (count($parts) !== 2) continue;
[$coll, $fname] = $parts;
foreach ($manifest['items'] as $item) {
if ($item['collection'] === $coll && $item['name'] === $fname) {
$home_item = $item;
$home_item['collection'] = 'home';
$home_item['_order'] = $priority;
$home_items[] = $home_item;
break;
}
}
}
usort($home_items, function($a, $b) { return $a['_order'] - $b['_order']; });
// Add home items to manifest and home to collections list
if (!empty($home_items)) {
$manifest['items'] = array_merge($home_items, $manifest['items']);
array_unshift($manifest['collections'], 'home');
}
// Strip internal _order field before passing to JS
foreach ($manifest['items'] as &$item) {
unset($item['_order']);
}
unset($item);
$about_md = file_exists('content/about.md') ? file_get_contents('content/about.md') : '';
$about_html = parse_markdown($about_md);
// Extract variables for templates
$name = htmlspecialchars($site['name']);
$tagline = htmlspecialchars($site['tagline']);
$full_title = "$name — $tagline";
$url = htmlspecialchars($site['url']);
$logo = htmlspecialchars($site['logo']);
$email = htmlspecialchars($site['email']);
$seo = $site['seo'];
$theme = $site['theme'];
$sd = $site['structured_data'];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $full_title ?></title>
<!-- SEO -->
<meta name="description" content="<?= htmlspecialchars($seo['description']) ?>">
<meta name="keywords" content="<?= htmlspecialchars($seo['keywords']) ?>">
<meta name="author" content="<?= $name ?>">
<link rel="canonical" href="<?= $url ?>/">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="<?= $full_title ?>">
<meta property="og:description" content="<?= htmlspecialchars($seo['og_description']) ?>">
<meta property="og:image" content="<?= $url ?>/<?= $logo ?>">
<meta property="og:url" content="<?= $url ?>/">
<meta property="og:site_name" content="<?= $name ?>">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?= $full_title ?>">
<meta name="twitter:description" content="<?= htmlspecialchars($seo['og_description']) ?>">
<meta name="twitter:image" content="<?= $url ?>/<?= $logo ?>">
<!-- Favicon -->
<link rel="icon" type="image/png" href="<?= $logo ?>">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "<?= htmlspecialchars($sd['type']) ?>",
"name": "<?= $name ?>",
"url": "<?= $url ?>",
"image": "<?= $url ?>/<?= $logo ?>",
"jobTitle": "<?= htmlspecialchars($sd['jobTitle']) ?>",
"description": "<?= htmlspecialchars($sd['description']) ?>",
<?php if (!empty($site['location'])): ?>
"address": {
"@type": "PostalAddress",
"addressLocality": "<?= htmlspecialchars(explode(',', $site['location'])[0]) ?>",
"addressRegion": "<?= htmlspecialchars(trim(explode(',', $site['location'])[1] ?? '')) ?>",
"addressCountry": "US"
},
<?php endif; ?>
"email": "<?= $email ?>",
"sameAs": [
<?= implode(",\n ", array_map(function($v) { return '"' . htmlspecialchars($v) . '"'; }, array_values($site['social']))) ?>
],
"knowsAbout": <?= json_encode($sd['knowsAbout']) ?>
}
</script>
<style>
/* ── Theme Custom Properties ── */
:root {
--bg: <?= $theme['background'] ?>;
--text: <?= $theme['text'] ?>;
--accent: <?= $theme['accent'] ?>;
--subtle: <?= $theme['subtle'] ?>;
--border: <?= $theme['border'] ?>;
}
/* ── Reset ── */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
/* ── Base ── */
body {
background: var(--bg);
color: var(--text);
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-weight: 300;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--subtle); text-decoration: none; }
a:hover { color: var(--accent); }
/* ── Header ── */
header {
height: 60px;
display: flex;
align-items: center;
gap: 24px;
padding: 0 40px;
border-bottom: 1px solid var(--border);
}
header a.logo {
display: block;
height: 36px;
line-height: 36px;
font-size: 14px;
font-weight: 300;
letter-spacing: 4px;
text-transform: uppercase;
color: var(--accent);
}
header a.logo img {
height: 100%;
width: auto;
}
header nav {
display: flex;
gap: 24px;
}
header nav a {
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
}
header nav a.active {
color: var(--accent);
}
/* ── Layout ── */
.layout {
display: flex;
min-height: calc(100vh - 60px - 60px);
}
/* ── Sidebar ── */
.sidebar {
width: 200px;
flex-shrink: 0;
padding: 40px 24px;
border-right: 1px solid var(--border);
}
.sidebar ul {
list-style: none;
}
.sidebar li {
margin-bottom: 12px;
}
.sidebar a {
font-size: 12px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--subtle);
transition: color 0.2s;
}
.sidebar a:hover { color: var(--accent); }
.sidebar a.active {
color: var(--accent);
font-weight: 400;
}
/* ── Gallery ── */
.gallery {
flex: 1;
padding: 40px;
overflow-y: auto;
}
.masonry {
column-count: 3;
column-gap: 16px;
}
.masonry-item {
position: relative;
break-inside: avoid;
margin-bottom: 16px;
}
.masonry-item img {
width: 100%;
display: block;
cursor: pointer;
opacity: 0.85;
transition: opacity 0.3s;
}
.masonry-item:hover img {
opacity: 1;
}
.info-icon {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
font-weight: 400;
font-style: italic;
font-family: Georgia, serif;
line-height: 18px;
text-align: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.3s;
z-index: 2;
}
.masonry-item:hover .info-icon {
opacity: 1;
}
.info-icon:hover {
background: rgba(0, 0, 0, 0.8);
color: var(--accent);
border-color: rgba(255, 255, 255, 0.6);
}
/* ── Info Page ── */
.info-page {
max-width: 800px;
}
.info-page img {
width: 100%;
max-width: 600px;
display: block;
margin-bottom: 32px;
}
.info-page .info-content {
font-size: 14px;
line-height: 1.8;
color: var(--text);
}
.info-page .info-content h1 {
font-size: 18px;
font-weight: 300;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 16px;
}
.info-page .info-content h2 {
font-size: 15px;
font-weight: 400;
color: var(--accent);
margin-top: 24px;
margin-bottom: 8px;
}
.info-page .info-content p {
margin-bottom: 16px;
}
.info-page .info-content strong {
color: var(--accent);
font-weight: 400;
}
.info-page .info-content a {
color: var(--subtle);
text-decoration: underline;
text-underline-offset: 2px;
}
.info-page .info-content a:hover {
color: var(--accent);
}
.info-page .back-link {
display: inline-block;
margin-bottom: 24px;
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--subtle);
}
.info-page .back-link:hover { color: var(--accent); }
/* ── About Page ── */
.about-page {
max-width: 800px;
}
.about-page img {
width: 100%;
max-width: 500px;
display: block;
margin-bottom: 32px;
}
.about-page h2 {
font-size: 18px;
font-weight: 300;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 24px;
}
.about-page p {
font-size: 14px;
line-height: 1.9;
color: var(--text);
margin-bottom: 20px;
}
.about-page .about-sig {
font-style: italic;
color: var(--subtle);
margin-top: 32px;
}
/* ── Contact Page ── */
.contact-page {
max-width: 600px;
}
.contact-page h2 {
font-size: 18px;
font-weight: 300;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 32px;
}
.contact-page .contact-method {
margin-bottom: 28px;
}
.contact-page .contact-label {
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--subtle);
margin-bottom: 6px;
}
.contact-page .contact-value {
font-size: 15px;
color: var(--text);
}
.contact-page .contact-value a {
color: var(--subtle);
text-decoration: underline;
text-underline-offset: 2px;
}
.contact-page .contact-value a:hover {
color: var(--accent);
}
.contact-page .contact-note {
margin-top: 40px;
font-size: 13px;
line-height: 1.8;
color: var(--subtle);
font-style: italic;
}
/* ── Lightbox ── */
.lightbox {
display: none;
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.95);
align-items: center;
justify-content: center;
}
.lightbox.open {
display: flex;
}
/* ── Lightbox Content ── */
.lightbox-content {
display: flex;
align-items: center;
justify-content: center;
max-width: 90vw;
max-height: 90vh;
}
.lightbox-content img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
}
.lightbox-close {
position: absolute;
top: 24px;
right: 32px;
font-size: 24px;
color: var(--subtle);
cursor: pointer;
background: none;
border: none;
font-weight: 300;
}
.lightbox-close:hover { color: var(--accent); }
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 32px;
color: var(--subtle);
cursor: pointer;
background: none;
border: none;
padding: 20px;
font-weight: 300;
}
.lightbox-nav:hover { color: var(--accent); }
.lightbox-prev { left: 16px; }
.lightbox-next { right: 16px; }
/* ── Video Thumbnail Overlay ── */
.video-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
opacity: 0;
transition: opacity 0.3s;
}
.masonry-item:hover .video-overlay { opacity: 1; }
/* ── Video Player ── */
.video-player {
position: relative;
width: 100%;
max-width: 90vw;
background: #000;
}
.video-player video {
width: 100%;
max-height: 70vh;
display: block;
cursor: pointer;
}
/* ── Buffering Spinner ── */
.video-buffering {
position: absolute;
inset: 0;
bottom: auto;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
pointer-events: none;
}
.video-buffering.visible { display: flex; }
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--subtle);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Progress Bar ── */
.video-progress-wrap {
position: relative;
width: 100%;
height: 6px;
background: var(--border);
cursor: pointer;
transition: height 0.15s;
}
.video-progress-wrap:hover { height: 10px; }
.video-progress-buffer {
position: absolute; top: 0; left: 0; height: 100%;
background: var(--subtle);
pointer-events: none;
}
.video-progress-bar {
position: absolute; top: 0; left: 0; height: 100%;
background: var(--accent);
pointer-events: none;
}
/* ── Controls Bar ── */
.video-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.9);
}
.vc-btn {
background: none; border: none;
color: var(--subtle);
cursor: pointer; font-size: 14px;
padding: 4px 8px; font-family: inherit;
}
.vc-btn:hover { color: var(--accent); }
.vc-play { font-size: 16px; }
.vc-skip { font-size: 11px; letter-spacing: 1px; }
.vc-time {
font-size: 12px; color: var(--subtle);
font-variant-numeric: tabular-nums;
}
.vc-spacer { flex: 1; }
.vc-quality {
font-size: 10px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--subtle);
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 3px;
}
/* ── Theater Mode ── */
.lightbox.theater-mode .lightbox-content { max-width: 95vw; }
.lightbox.theater-mode .video-player { max-width: 95vw; }
.lightbox.theater-mode .video-player video { max-height: 85vh; }
/* ── Footer ── */
footer {
height: 60px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 40px;
border-top: 1px solid var(--border);
}
footer span {
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--subtle);
}
/* ── Loading ── */
.loading {
color: var(--subtle);
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
padding: 80px 0;
text-align: center;
}
/* ── Responsive ── */
@media (max-width: 768px) {
header {
height: auto;
padding: 24px 20px;
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.layout {
flex-direction: column;
}
.sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 20px;
}
.sidebar ul {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
.sidebar li { margin-bottom: 0; }
.gallery { padding: 20px; }
.masonry { column-count: 2; column-gap: 10px; }
.masonry-item { margin-bottom: 10px; }
}
@media (max-width: 480px) {
.masonry { column-count: 1; }
}
</style>
<script src="vendor/hls.min.js"></script>
</head>
<body>
<header>
<a href="#" class="logo" id="logo-link"><?php if ($logo && file_exists($logo)): ?><img src="<?= $logo ?>" alt="<?= $full_title ?>"><?php else: ?><?= $name ?><?php endif; ?></a>
<nav>
<a href="#" id="about-link">about</a>
<a href="#" id="contact-link">contact</a>
</nav>
</header>
<div class="layout">
<aside class="sidebar">
<ul id="collections">
<li><a href="#" class="active" data-collection="home">Home</a></li>
</ul>
</aside>
<main class="gallery">
<div class="masonry" id="gallery">
<p class="loading">Loading...</p>
</div>
</main>
</div>
<template id="about-content">
<div class="about-page">
<img src="<?= htmlspecialchars($site['about']['image']) ?>" alt="<?= $name ?>">
<?= $about_html ?>
<p class="about-sig"><?= htmlspecialchars($site['about']['signature']) ?></p>
</div>
</template>
<template id="contact-content">
<div class="contact-page">
<h2>Get in Touch</h2>
<div class="contact-method">
<div class="contact-label">Email</div>
<div class="contact-value"><a href="mailto:<?= $email ?>"><?= $email ?></a></div>
</div>
<?php foreach ($site['social'] as $platform => $link): ?>
<div class="contact-method">
<div class="contact-label"><?= htmlspecialchars(ucfirst($platform)) ?></div>
<div class="contact-value"><a href="<?= htmlspecialchars($link) ?>" target="_blank">@<?= $name ?></a></div>
</div>
<?php endforeach; ?>
<?php if (!empty($site['location'])): ?>
<div class="contact-method">
<div class="contact-label">Location</div>
<div class="contact-value"><?= htmlspecialchars($site['location']) ?></div>
</div>
<?php endif; ?>
<p class="contact-note"><?= htmlspecialchars($site['contact']['note']) ?></p>
</div>
</template>
<div class="lightbox" id="lightbox">
<button class="lightbox-close" aria-label="Close">×</button>
<button class="lightbox-nav lightbox-prev" aria-label="Previous">‹</button>
<div class="lightbox-content" id="lightbox-content"></div>
<button class="lightbox-nav lightbox-next" aria-label="Next">›</button>
</div>
<footer>
<span>© <?= strtolower($name) ?></span>
</footer>
<script>
// ── Config ── //
const MANIFEST = <?= json_encode($manifest) ?>;
const SITE = <?= json_encode(['name' => $site['name'], 'collections' => $site['collections']]) ?>;
// ── Helpers ── //
function prettifyName(slug) {
return slug
.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
.replace(' And ', ' & ');
}
function formatDuration(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
}
// ── Minimal Markdown Parser (JS) ── //
function parseMarkdown(md) {
return md
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
.replace(/\n\n+/g, '</p><p>')
.replace(/^/, '<p>')
.replace(/$/, '</p>')
.replace(/<p>(<h[123]>)/g, '$1')
.replace(/(<\/h[123]>)<\/p>/g, '$1')
.replace(/<p><\/p>/g, '');
}
// ── View State ── //
let currentView = 'gallery';
let activeCollection = 'home';
// ── Sidebar ── //
function renderSidebar() {
const ul = document.getElementById('collections');
const allLink = ul.querySelector('[data-collection="home"]');
// Remove old dynamic links
ul.querySelectorAll('li:not(:first-child)').forEach(li => li.remove());
// Use configured order, then append any manifest collections not in the order
const configOrder = (SITE.collections && SITE.collections.order) || [];
const manifestColls = MANIFEST.collections || [];
const seen = new Set(configOrder);
const allColls = [...configOrder.filter(c => manifestColls.includes(c))];
for (const c of manifestColls) {
if (!seen.has(c)) allColls.push(c);
}
for (const coll of allColls) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '#';
a.dataset.collection = coll;
a.textContent = prettifyName(coll);
if (coll === activeCollection) a.classList.add('active');
li.appendChild(a);
ul.appendChild(li);
}
// Update All Works active state
if (activeCollection === 'all') {
allLink.classList.add('active');
} else {
allLink.classList.remove('active');
}
}
// Sidebar click handler (delegated, added once)
document.getElementById('collections').addEventListener('click', (e) => {
if (e.target.tagName !== 'A') return;
e.preventDefault();
activeCollection = e.target.dataset.collection;
document.querySelectorAll('#collections a').forEach(a => a.classList.remove('active'));
e.target.classList.add('active');
document.getElementById('about-link').classList.remove('active');
document.getElementById('contact-link').classList.remove('active');
renderGallery();
});
// ── Gallery ── //
function getFilteredItems() {
const items = MANIFEST.items || [];
return items.filter(item => item.collection === activeCollection);
}
function renderGallery() {
currentView = 'gallery';
const container = document.getElementById('gallery');
container.innerHTML = '';
container.className = 'masonry';
const items = getFilteredItems();
if (items.length === 0) {
container.innerHTML = '<p class="loading">No items found</p>';
return;
}
items.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'masonry-item';
const img = document.createElement('img');
img.src = item.thumb;
img.alt = (item.name || '').replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
img.loading = 'lazy';
img.addEventListener('click', () => openLightbox(items, index));
div.appendChild(img);
// Video overlay
if (item.type === 'video') {
const overlay = document.createElement('div');
overlay.className = 'video-overlay';
overlay.innerHTML = '▶';
overlay.addEventListener('click', (e) => {
e.stopPropagation();
openLightbox(items, index);
});
div.appendChild(overlay);
}
// Info icon
if (item.hasInfo) {
const icon = document.createElement('span');
icon.className = 'info-icon';
icon.textContent = 'i';
icon.addEventListener('click', (e) => {
e.stopPropagation();
showInfoPage(item);
});
div.appendChild(icon);
}
container.appendChild(div);
});
}
// ── About Page ── //
function showAboutPage() {
currentView = 'about';
// Clear sidebar active
document.querySelectorAll('#collections a').forEach(a => a.classList.remove('active'));
document.getElementById('about-link').classList.add('active');
document.getElementById('contact-link').classList.remove('active');
const container = document.getElementById('gallery');
container.className = '';
container.innerHTML = '';
const template = document.getElementById('about-content');
const clone = template.content.cloneNode(true);
container.appendChild(clone);
}
// ── Contact Page ── //
function showContactPage() {
currentView = 'contact';
document.querySelectorAll('#collections a').forEach(a => a.classList.remove('active'));
document.getElementById('about-link').classList.remove('active');
document.getElementById('contact-link').classList.add('active');
const container = document.getElementById('gallery');
container.className = '';
container.innerHTML = '';
const template = document.getElementById('contact-content');
const clone = template.content.cloneNode(true);
container.appendChild(clone);
}
// ── Info Page ── //
async function showInfoPage(item) {
const container = document.getElementById('gallery');
container.className = '';
container.innerHTML = '<p class="loading">Loading...</p>';
currentView = 'info';
try {
const res = await fetch(item.mdUrl, { cache: 'no-cache' });
if (!res.ok) throw new Error('Not found');
const md = await res.text();
container.innerHTML = '';
const page = document.createElement('div');
page.className = 'info-page';
const back = document.createElement('a');
back.href = '#';
back.className = 'back-link';
back.textContent = '\u2190 back to gallery';
back.addEventListener('click', (e) => {
e.preventDefault();
renderGallery();
});
page.appendChild(back);
const image = document.createElement('img');
image.src = item.full;
image.alt = (item.name || '').replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
page.appendChild(image);
const content = document.createElement('div');
content.className = 'info-content';
content.innerHTML = parseMarkdown(md);
page.appendChild(content);
container.appendChild(page);
} catch {
container.innerHTML = '<p class="loading">Could not load info</p>';
}
}
// ── Header Click Handlers ── //
document.getElementById('logo-link').addEventListener('click', (e) => {
e.preventDefault();
activeCollection = 'home';
document.querySelectorAll('#collections a').forEach(a => a.classList.remove('active'));
const allLink = document.querySelector('[data-collection="home"]');
if (allLink) allLink.classList.add('active');
document.getElementById('about-link').classList.remove('active');
document.getElementById('contact-link').classList.remove('active');
renderGallery();
});
document.getElementById('about-link').addEventListener('click', (e) => {
e.preventDefault();
showAboutPage();
});
document.getElementById('contact-link').addEventListener('click', (e) => {
e.preventDefault();
showContactPage();
});
// ── Lightbox ── //
let lightboxItems = [];
let lightboxIndex = 0;
function stopCurrentMedia() {
if (activeHls) {
activeHls.destroy();
activeHls = null;
}
const video = document.querySelector('#lightbox-content video');
if (video) {
video.pause();
video.src = '';
}
}
function renderLightboxItem() {
const container = document.getElementById('lightbox-content');
stopCurrentMedia();
container.innerHTML = '';
const item = lightboxItems[lightboxIndex];
if (!item) return;
if (item.type === 'video') {
renderVideoPlayer(container, item);
} else {
const img = document.createElement('img');
img.src = item.full;
img.alt = (item.name || '').replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
container.appendChild(img);
}
}
function openLightbox(items, index) {
lightboxItems = items;
lightboxIndex = index;
renderLightboxItem();
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() {
stopCurrentMedia();
document.getElementById('lightbox').classList.remove('open');
document.getElementById('lightbox').classList.remove('theater-mode');
}
function navigateLightbox(delta) {
if (lightboxItems.length === 0) return;
lightboxIndex = (lightboxIndex + delta + lightboxItems.length) % lightboxItems.length;
renderLightboxItem();
}
// Lightbox event listeners
document.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
document.querySelector('.lightbox-prev').addEventListener('click', () => navigateLightbox(-1));
document.querySelector('.lightbox-next').addEventListener('click', () => navigateLightbox(1));
document.getElementById('lightbox').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeLightbox();
});
document.addEventListener('keydown', (e) => {
const lb = document.getElementById('lightbox');
if (!lb.classList.contains('open')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') navigateLightbox(-1);
if (e.key === 'ArrowRight') navigateLightbox(1);
});
// ── Custom Video Player ── //
let activeHls = null; // track active hls.js instance for cleanup
function renderVideoPlayer(container, item) {
// Clean up previous HLS instance
if (activeHls) {
activeHls.destroy();
activeHls = null;
}
const wrapper = document.createElement('div');
wrapper.className = 'video-player';
// Video element
const video = document.createElement('video');
video.preload = 'metadata';
// HLS or direct MP4
if (item.hls && typeof Hls !== 'undefined' && Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(item.hls);
hls.attachMedia(video);
activeHls = hls;
} else if (item.hls && video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS
video.src = item.hls;
} else {
// Fallback to plain MP4
video.src = item.full;
}
video.addEventListener('click', () => {
if (video.paused) video.play();
else video.pause();
});
wrapper.appendChild(video);
// Buffering spinner
const buffering = document.createElement('div');
buffering.className = 'video-buffering';
buffering.innerHTML = '<div class="spinner"></div>';
wrapper.appendChild(buffering);
video.addEventListener('waiting', () => buffering.classList.add('visible'));
video.addEventListener('canplay', () => buffering.classList.remove('visible'));
video.addEventListener('playing', () => buffering.classList.remove('visible'));
// Progress bar
const progressWrap = document.createElement('div');
progressWrap.className = 'video-progress-wrap';
const bufferBar = document.createElement('div');
bufferBar.className = 'video-progress-buffer';
progressWrap.appendChild(bufferBar);
const progressBar = document.createElement('div');
progressBar.className = 'video-progress-bar';
progressWrap.appendChild(progressBar);
wrapper.appendChild(progressWrap);
// Seeking via mouse
let seeking = false;
function seekTo(e) {
const rect = progressWrap.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
if (video.duration) video.currentTime = ratio * video.duration;
}
progressWrap.addEventListener('mousedown', (e) => {
seeking = true;
seekTo(e);
});
document.addEventListener('mousemove', (e) => {
if (seeking) seekTo(e);
});
document.addEventListener('mouseup', () => {
seeking = false;
});
// Seeking via touch
progressWrap.addEventListener('touchstart', (e) => {
seeking = true;
const touch = e.touches[0];
const rect = progressWrap.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
if (video.duration) video.currentTime = ratio * video.duration;
});
progressWrap.addEventListener('touchmove', (e) => {
if (!seeking) return;
const touch = e.touches[0];
const rect = progressWrap.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
if (video.duration) video.currentTime = ratio * video.duration;
});
progressWrap.addEventListener('touchend', () => {
seeking = false;
});
// Controls bar
const controls = document.createElement('div');
controls.className = 'video-controls';
// Play/Pause button
const playBtn = document.createElement('button');
playBtn.className = 'vc-btn vc-play';
playBtn.innerHTML = '▶';
playBtn.addEventListener('click', () => {
if (video.paused) video.play();
else video.pause();
});
controls.appendChild(playBtn);
// Skip -10s
const skipBack = document.createElement('button');
skipBack.className = 'vc-btn vc-skip';
skipBack.textContent = '-10s';
skipBack.addEventListener('click', () => {
video.currentTime = Math.max(0, video.currentTime - 10);
});
controls.appendChild(skipBack);
// Skip +10s
const skipFwd = document.createElement('button');
skipFwd.className = 'vc-btn vc-skip';
skipFwd.textContent = '+10s';
skipFwd.addEventListener('click', () => {
video.currentTime = Math.min(video.duration || 0, video.currentTime + 10);
});
controls.appendChild(skipFwd);
// Time display
const timeDisplay = document.createElement('span');
timeDisplay.className = 'vc-time';
timeDisplay.textContent = '0:00 / 0:00';
controls.appendChild(timeDisplay);
// Spacer
const spacer = document.createElement('span');
spacer.className = 'vc-spacer';
controls.appendChild(spacer);
// Quality indicator (HLS only)
const qualBadge = document.createElement('span');
qualBadge.className = 'vc-quality';
qualBadge.textContent = '';
if (activeHls) {
qualBadge.textContent = 'auto';
activeHls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => {
const level = activeHls.levels[data.level];
if (level) qualBadge.textContent = level.height + 'p';
});
}
controls.appendChild(qualBadge);
// Volume toggle
const volBtn = document.createElement('button');
volBtn.className = 'vc-btn';
volBtn.innerHTML = '🔈';
volBtn.addEventListener('click', () => {
video.muted = !video.muted;
volBtn.innerHTML = video.muted ? '🔇' : '🔈';
});
controls.appendChild(volBtn);
// Theater mode toggle
const theaterBtn = document.createElement('button');
theaterBtn.className = 'vc-btn';
theaterBtn.innerHTML = '▣';
theaterBtn.addEventListener('click', () => {
document.getElementById('lightbox').classList.toggle('theater-mode');
});
controls.appendChild(theaterBtn);
// Fullscreen toggle
const fsBtn = document.createElement('button');
fsBtn.className = 'vc-btn';
fsBtn.innerHTML = '⛶';
fsBtn.addEventListener('click', () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrapper.requestFullscreen().catch(() => {});
}
});
controls.appendChild(fsBtn);
wrapper.appendChild(controls);
// Video events
video.addEventListener('play', () => {
playBtn.innerHTML = '▮▮';
});
video.addEventListener('pause', () => {
playBtn.innerHTML = '▶';
});
video.addEventListener('timeupdate', () => {
if (!video.duration) return;
const pct = (video.currentTime / video.duration) * 100;
progressBar.style.width = pct + '%';
timeDisplay.textContent = formatDuration(video.currentTime) + ' / ' + formatDuration(video.duration);
});
video.addEventListener('progress', () => {
if (!video.duration || !video.buffered.length) return;
const end = video.buffered.end(video.buffered.length - 1);
bufferBar.style.width = (end / video.duration) * 100 + '%';
});
container.appendChild(wrapper);
}
// ── Touch Swipe for Lightbox ── //
let touchStartX = 0;
let touchStartY = 0;
document.getElementById('lightbox').addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
});
document.getElementById('lightbox').addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
// Only horizontal swipes with >50px threshold
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
if (dx > 0) navigateLightbox(-1);
else navigateLightbox(1);
}
});
// ── Init ── //
function init() {
renderSidebar();
renderGallery();
}
init();
</script>
</body>
</html>