OverviewFilesCommitsIssuesDocsReleasesPeers

folio / index.php

1328 lines · php ref:
<?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">&times;</button>
    <button class="lightbox-nav lightbox-prev" aria-label="Previous">&lsaquo;</button>
    <div class="lightbox-content" id="lightbox-content"></div>
    <button class="lightbox-nav lightbox-next" aria-label="Next">&rsaquo;</button>
  </div>

  <footer>
    <span>&copy; <?= 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 = '&#9654;';
          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 = '&#9654;';
      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 = '&#128264;';
      volBtn.addEventListener('click', () => {
        video.muted = !video.muted;
        volBtn.innerHTML = video.muted ? '&#128263;' : '&#128264;';
      });
      controls.appendChild(volBtn);

      // Theater mode toggle
      const theaterBtn = document.createElement('button');
      theaterBtn.className = 'vc-btn';
      theaterBtn.innerHTML = '&#9635;';
      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 = '&#x26F6;';
      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 = '&#9646;&#9646;';
      });

      video.addEventListener('pause', () => {
        playBtn.innerHTML = '&#9654;';
      });

      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>