OverviewFilesCommitsIssuesDocsReleasesPeers

folio commits / e4b06a3

Add index.php: config-driven PHP template with video player
Complete single-file template with: - PHP server-side rendering of config, SEO, markdown content - CSS with theme custom properties - Manifest-based gallery with collection filtering - Image lightbox with keyboard navigation - Custom video player with seek, skip, buffer, theater mode - Touch swipe navigation - About/contact pages from templates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
e4b06a3678c3e911af9c57f33df905d14095ed1f
by plankatron <fleeky@gmail.com> · 2026-03-17T16:51:44.000Z

Changed files (1) +1215 -0

index.php +1215 -0
……@@ -0,0 +1,1215 @@
1<?php
2require_once 'lib/markdown.php';
3$site = json_decode(file_get_contents('site.json'), true);
4$manifest = file_exists('manifest.json')
5 ? json_decode(file_get_contents('manifest.json'), true)
6 : ['collections' => [], 'items' => []];
7$about_md = file_exists('content/about.md') ? file_get_contents('content/about.md') : '';
8$about_html = parse_markdown($about_md);
9// Extract variables for templates
10$name = htmlspecialchars($site['name']);
11$tagline = htmlspecialchars($site['tagline']);
12$full_title = "$name — $tagline";
13$url = htmlspecialchars($site['url']);
14$logo = htmlspecialchars($site['logo']);
15$email = htmlspecialchars($site['email']);
16$seo = $site['seo'];
17$theme = $site['theme'];
18$sd = $site['structured_data'];
19?>
20<!DOCTYPE html>
21<html lang="en">
22<head>
23 <meta charset="UTF-8">
24 <meta name="viewport" content="width=device-width, initial-scale=1.0">
25 <title><?= $full_title ?></title>
26
27 <!-- SEO -->
28 <meta name="description" content="<?= htmlspecialchars($seo['description']) ?>">
29 <meta name="keywords" content="<?= htmlspecialchars($seo['keywords']) ?>">
30 <meta name="author" content="<?= $name ?>">
31 <link rel="canonical" href="<?= $url ?>/">
32
33 <!-- Open Graph -->
34 <meta property="og:type" content="website">
35 <meta property="og:title" content="<?= $full_title ?>">
36 <meta property="og:description" content="<?= htmlspecialchars($seo['og_description']) ?>">
37 <meta property="og:image" content="<?= $url ?>/<?= $logo ?>">
38 <meta property="og:url" content="<?= $url ?>/">
39 <meta property="og:site_name" content="<?= $name ?>">
40
41 <!-- Twitter Card -->
42 <meta name="twitter:card" content="summary_large_image">
43 <meta name="twitter:title" content="<?= $full_title ?>">
44 <meta name="twitter:description" content="<?= htmlspecialchars($seo['og_description']) ?>">
45 <meta name="twitter:image" content="<?= $url ?>/<?= $logo ?>">
46
47 <!-- Favicon -->
48 <link rel="icon" type="image/png" href="<?= $logo ?>">
49
50 <!-- Structured Data -->
51 <script type="application/ld+json">
52 {
53 "@context": "https://schema.org",
54 "@type": "<?= htmlspecialchars($sd['type']) ?>",
55 "name": "<?= $name ?>",
56 "url": "<?= $url ?>",
57 "image": "<?= $url ?>/<?= $logo ?>",
58 "jobTitle": "<?= htmlspecialchars($sd['jobTitle']) ?>",
59 "description": "<?= htmlspecialchars($sd['description']) ?>",
60 <?php if (!empty($site['location'])): ?>
61 "address": {
62 "@type": "PostalAddress",
63 "addressLocality": "<?= htmlspecialchars(explode(',', $site['location'])[0]) ?>",
64 "addressRegion": "<?= htmlspecialchars(trim(explode(',', $site['location'])[1] ?? '')) ?>",
65 "addressCountry": "US"
66 },
67 <?php endif; ?>
68 "email": "<?= $email ?>",
69 "sameAs": [
70 <?= implode(",\n ", array_map(function($v) { return '"' . htmlspecialchars($v) . '"'; }, array_values($site['social']))) ?>
71
72 ],
73 "knowsAbout": <?= json_encode($sd['knowsAbout']) ?>
74
75 }
76 </script>
77
78 <style>
79 /* ── Theme Custom Properties ── */
80 :root {
81 --bg: <?= $theme['background'] ?>;
82 --text: <?= $theme['text'] ?>;
83 --accent: <?= $theme['accent'] ?>;
84 --subtle: <?= $theme['subtle'] ?>;
85 --border: <?= $theme['border'] ?>;
86 }
87
88 /* ── Reset ── */
89 *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
90
91 /* ── Base ── */
92 body {
93 background: var(--bg);
94 color: var(--text);
95 font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
96 font-weight: 300;
97 line-height: 1.5;
98 -webkit-font-smoothing: antialiased;
99 }
100
101 a { color: var(--subtle); text-decoration: none; }
102 a:hover { color: var(--accent); }
103
104 /* ── Header ── */
105 header {
106 height: 60px;
107 display: flex;
108 align-items: center;
109 justify-content: space-between;
110 padding: 0 40px;
111 border-bottom: 1px solid var(--border);
112 }
113
114 header a.logo {
115 display: block;
116 height: 36px;
117 }
118
119 header a.logo img {
120 height: 100%;
121 width: auto;
122 }
123
124 header nav {
125 display: flex;
126 gap: 24px;
127 }
128
129 header nav a {
130 font-size: 11px;
131 letter-spacing: 2px;
132 text-transform: uppercase;
133 }
134
135 header nav a.active {
136 color: var(--accent);
137 }
138
139 /* ── Layout ── */
140 .layout {
141 display: flex;
142 min-height: calc(100vh - 60px - 60px);
143 }
144
145 /* ── Sidebar ── */
146 .sidebar {
147 width: 200px;
148 flex-shrink: 0;
149 padding: 40px 24px;
150 border-right: 1px solid var(--border);
151 }
152
153 .sidebar ul {
154 list-style: none;
155 }
156
157 .sidebar li {
158 margin-bottom: 12px;
159 }
160
161 .sidebar a {
162 font-size: 12px;
163 letter-spacing: 1px;
164 text-transform: uppercase;
165 color: var(--subtle);
166 transition: color 0.2s;
167 }
168
169 .sidebar a:hover { color: var(--accent); }
170
171 .sidebar a.active {
172 color: var(--accent);
173 font-weight: 400;
174 }
175
176 /* ── Gallery ── */
177 .gallery {
178 flex: 1;
179 padding: 40px;
180 overflow-y: auto;
181 }
182
183 .masonry {
184 column-count: 3;
185 column-gap: 16px;
186 }
187
188 .masonry-item {
189 position: relative;
190 break-inside: avoid;
191 margin-bottom: 16px;
192 }
193
194 .masonry-item img {
195 width: 100%;
196 display: block;
197 cursor: pointer;
198 opacity: 0.85;
199 transition: opacity 0.3s;
200 }
201
202 .masonry-item:hover img {
203 opacity: 1;
204 }
205
206 .info-icon {
207 position: absolute;
208 top: 8px;
209 right: 8px;
210 width: 20px;
211 height: 20px;
212 background: rgba(0, 0, 0, 0.6);
213 border: 1px solid rgba(255, 255, 255, 0.3);
214 border-radius: 50%;
215 color: rgba(255, 255, 255, 0.5);
216 font-size: 11px;
217 font-weight: 400;
218 font-style: italic;
219 font-family: Georgia, serif;
220 line-height: 18px;
221 text-align: center;
222 cursor: pointer;
223 opacity: 0;
224 transition: opacity 0.3s;
225 z-index: 2;
226 }
227
228 .masonry-item:hover .info-icon {
229 opacity: 1;
230 }
231
232 .info-icon:hover {
233 background: rgba(0, 0, 0, 0.8);
234 color: var(--accent);
235 border-color: rgba(255, 255, 255, 0.6);
236 }
237
238 /* ── Info Page ── */
239 .info-page {
240 max-width: 800px;
241 }
242
243 .info-page img {
244 width: 100%;
245 max-width: 600px;
246 display: block;
247 margin-bottom: 32px;
248 }
249
250 .info-page .info-content {
251 font-size: 14px;
252 line-height: 1.8;
253 color: var(--text);
254 }
255
256 .info-page .info-content h1 {
257 font-size: 18px;
258 font-weight: 300;
259 letter-spacing: 2px;
260 text-transform: uppercase;
261 color: var(--accent);
262 margin-bottom: 16px;
263 }
264
265 .info-page .info-content h2 {
266 font-size: 15px;
267 font-weight: 400;
268 color: var(--accent);
269 margin-top: 24px;
270 margin-bottom: 8px;
271 }
272
273 .info-page .info-content p {
274 margin-bottom: 16px;
275 }
276
277 .info-page .info-content strong {
278 color: var(--accent);
279 font-weight: 400;
280 }
281
282 .info-page .info-content a {
283 color: var(--subtle);
284 text-decoration: underline;
285 text-underline-offset: 2px;
286 }
287
288 .info-page .info-content a:hover {
289 color: var(--accent);
290 }
291
292 .info-page .back-link {
293 display: inline-block;
294 margin-bottom: 24px;
295 font-size: 11px;
296 letter-spacing: 2px;
297 text-transform: uppercase;
298 color: var(--subtle);
299 }
300
301 .info-page .back-link:hover { color: var(--accent); }
302
303 /* ── About Page ── */
304 .about-page {
305 max-width: 800px;
306 }
307
308 .about-page img {
309 width: 100%;
310 max-width: 500px;
311 display: block;
312 margin-bottom: 32px;
313 }
314
315 .about-page h2 {
316 font-size: 18px;
317 font-weight: 300;
318 letter-spacing: 2px;
319 text-transform: uppercase;
320 color: var(--accent);
321 margin-bottom: 24px;
322 }
323
324 .about-page p {
325 font-size: 14px;
326 line-height: 1.9;
327 color: var(--text);
328 margin-bottom: 20px;
329 }
330
331 .about-page .about-sig {
332 font-style: italic;
333 color: var(--subtle);
334 margin-top: 32px;
335 }
336
337 /* ── Contact Page ── */
338 .contact-page {
339 max-width: 600px;
340 }
341
342 .contact-page h2 {
343 font-size: 18px;
344 font-weight: 300;
345 letter-spacing: 2px;
346 text-transform: uppercase;
347 color: var(--accent);
348 margin-bottom: 32px;
349 }
350
351 .contact-page .contact-method {
352 margin-bottom: 28px;
353 }
354
355 .contact-page .contact-label {
356 font-size: 11px;
357 letter-spacing: 2px;
358 text-transform: uppercase;
359 color: var(--subtle);
360 margin-bottom: 6px;
361 }
362
363 .contact-page .contact-value {
364 font-size: 15px;
365 color: var(--text);
366 }
367
368 .contact-page .contact-value a {
369 color: var(--subtle);
370 text-decoration: underline;
371 text-underline-offset: 2px;
372 }
373
374 .contact-page .contact-value a:hover {
375 color: var(--accent);
376 }
377
378 .contact-page .contact-note {
379 margin-top: 40px;
380 font-size: 13px;
381 line-height: 1.8;
382 color: var(--subtle);
383 font-style: italic;
384 }
385
386 /* ── Lightbox ── */
387 .lightbox {
388 display: none;
389 position: fixed;
390 inset: 0;
391 z-index: 100;
392 background: rgba(0, 0, 0, 0.95);
393 align-items: center;
394 justify-content: center;
395 }
396
397 .lightbox.open {
398 display: flex;
399 }
400
401 /* ── Lightbox Content ── */
402 .lightbox-content {
403 display: flex;
404 align-items: center;
405 justify-content: center;
406 max-width: 90vw;
407 max-height: 90vh;
408 }
409
410 .lightbox-content img {
411 max-width: 90vw;
412 max-height: 90vh;
413 object-fit: contain;
414 }
415
416 .lightbox-close {
417 position: absolute;
418 top: 24px;
419 right: 32px;
420 font-size: 24px;
421 color: var(--subtle);
422 cursor: pointer;
423 background: none;
424 border: none;
425 font-weight: 300;
426 }
427
428 .lightbox-close:hover { color: var(--accent); }
429
430 .lightbox-nav {
431 position: absolute;
432 top: 50%;
433 transform: translateY(-50%);
434 font-size: 32px;
435 color: var(--subtle);
436 cursor: pointer;
437 background: none;
438 border: none;
439 padding: 20px;
440 font-weight: 300;
441 }
442
443 .lightbox-nav:hover { color: var(--accent); }
444 .lightbox-prev { left: 16px; }
445 .lightbox-next { right: 16px; }
446
447 /* ── Video Thumbnail Overlay ── */
448 .video-overlay {
449 position: absolute;
450 inset: 0;
451 display: flex;
452 align-items: center;
453 justify-content: center;
454 font-size: 36px;
455 color: rgba(255, 255, 255, 0.7);
456 background: rgba(0, 0, 0, 0.2);
457 cursor: pointer;
458 opacity: 0;
459 transition: opacity 0.3s;
460 }
461 .masonry-item:hover .video-overlay { opacity: 1; }
462
463 /* ── Video Player ── */
464 .video-player {
465 position: relative;
466 width: 100%;
467 max-width: 90vw;
468 background: #000;
469 }
470 .video-player video {
471 width: 100%;
472 max-height: 70vh;
473 display: block;
474 cursor: pointer;
475 }
476
477 /* ── Buffering Spinner ── */
478 .video-buffering {
479 position: absolute;
480 inset: 0;
481 bottom: auto;
482 display: none;
483 align-items: center;
484 justify-content: center;
485 background: rgba(0, 0, 0, 0.4);
486 pointer-events: none;
487 }
488 .video-buffering.visible { display: flex; }
489 .spinner {
490 width: 40px;
491 height: 40px;
492 border: 3px solid var(--subtle);
493 border-top-color: var(--accent);
494 border-radius: 50%;
495 animation: spin 0.8s linear infinite;
496 }
497 @keyframes spin { to { transform: rotate(360deg); } }
498
499 /* ── Progress Bar ── */
500 .video-progress-wrap {
501 position: relative;
502 width: 100%;
503 height: 6px;
504 background: var(--border);
505 cursor: pointer;
506 transition: height 0.15s;
507 }
508 .video-progress-wrap:hover { height: 10px; }
509 .video-progress-buffer {
510 position: absolute; top: 0; left: 0; height: 100%;
511 background: var(--subtle);
512 pointer-events: none;
513 }
514 .video-progress-bar {
515 position: absolute; top: 0; left: 0; height: 100%;
516 background: var(--accent);
517 pointer-events: none;
518 }
519
520 /* ── Controls Bar ── */
521 .video-controls {
522 display: flex;
523 align-items: center;
524 gap: 8px;
525 padding: 8px 12px;
526 background: rgba(0, 0, 0, 0.9);
527 }
528 .vc-btn {
529 background: none; border: none;
530 color: var(--subtle);
531 cursor: pointer; font-size: 14px;
532 padding: 4px 8px; font-family: inherit;
533 }
534 .vc-btn:hover { color: var(--accent); }
535 .vc-play { font-size: 16px; }
536 .vc-skip { font-size: 11px; letter-spacing: 1px; }
537 .vc-time {
538 font-size: 12px; color: var(--subtle);
539 font-variant-numeric: tabular-nums;
540 }
541 .vc-spacer { flex: 1; }
542
543 /* ── Theater Mode ── */
544 .lightbox.theater-mode .lightbox-content { max-width: 95vw; }
545 .lightbox.theater-mode .video-player { max-width: 95vw; }
546 .lightbox.theater-mode .video-player video { max-height: 85vh; }
547
548 /* ── Footer ── */
549 footer {
550 height: 60px;
551 display: flex;
552 align-items: center;
553 justify-content: flex-end;
554 padding: 0 40px;
555 border-top: 1px solid var(--border);
556 }
557
558 footer span {
559 font-size: 11px;
560 letter-spacing: 2px;
561 text-transform: uppercase;
562 color: var(--subtle);
563 }
564
565 /* ── Loading ── */
566 .loading {
567 color: var(--subtle);
568 font-size: 11px;
569 letter-spacing: 2px;
570 text-transform: uppercase;
571 padding: 80px 0;
572 text-align: center;
573 }
574
575 /* ── Responsive ── */
576 @media (max-width: 768px) {
577 header {
578 height: auto;
579 padding: 24px 20px;
580 flex-direction: column;
581 gap: 16px;
582 align-items: flex-start;
583 }
584
585 .layout {
586 flex-direction: column;
587 }
588
589 .sidebar {
590 width: 100%;
591 border-right: none;
592 border-bottom: 1px solid var(--border);
593 padding: 20px;
594 }
595
596 .sidebar ul {
597 display: flex;
598 flex-wrap: wrap;
599 gap: 8px 16px;
600 }
601
602 .sidebar li { margin-bottom: 0; }
603
604 .gallery { padding: 20px; }
605
606 .masonry { column-count: 2; column-gap: 10px; }
607 .masonry-item { margin-bottom: 10px; }
608 }
609
610 @media (max-width: 480px) {
611 .masonry { column-count: 1; }
612 }
613 </style>
614</head>
615<body>
616
617 <header>
618 <a href="#" class="logo" id="logo-link"><img src="<?= $logo ?>" alt="<?= $full_title ?>"></a>
619 <nav>
620 <a href="#" id="about-link">about</a>
621 <a href="#" id="contact-link">contact</a>
622 </nav>
623 </header>
624
625 <div class="layout">
626 <aside class="sidebar">
627 <ul id="collections">
628 <li><a href="#" class="active" data-collection="all">All Works</a></li>
629 </ul>
630 </aside>
631
632 <main class="gallery">
633 <div class="masonry" id="gallery">
634 <p class="loading">Loading...</p>
635 </div>
636 </main>
637 </div>
638
639 <template id="about-content">
640 <div class="about-page">
641 <img src="<?= htmlspecialchars($site['about']['image']) ?>" alt="<?= $name ?>">
642 <?= $about_html ?>
643 <p class="about-sig"><?= htmlspecialchars($site['about']['signature']) ?></p>
644 </div>
645 </template>
646
647 <template id="contact-content">
648 <div class="contact-page">
649 <h2>Get in Touch</h2>
650 <div class="contact-method">
651 <div class="contact-label">Email</div>
652 <div class="contact-value"><a href="mailto:<?= $email ?>"><?= $email ?></a></div>
653 </div>
654 <?php foreach ($site['social'] as $platform => $link): ?>
655 <div class="contact-method">
656 <div class="contact-label"><?= htmlspecialchars(ucfirst($platform)) ?></div>
657 <div class="contact-value"><a href="<?= htmlspecialchars($link) ?>" target="_blank">@<?= $name ?></a></div>
658 </div>
659 <?php endforeach; ?>
660 <?php if (!empty($site['location'])): ?>
661 <div class="contact-method">
662 <div class="contact-label">Location</div>
663 <div class="contact-value"><?= htmlspecialchars($site['location']) ?></div>
664 </div>
665 <?php endif; ?>
666 <p class="contact-note"><?= htmlspecialchars($site['contact']['note']) ?></p>
667 </div>
668 </template>
669
670 <div class="lightbox" id="lightbox">
671 <button class="lightbox-close" aria-label="Close">&times;</button>
672 <button class="lightbox-nav lightbox-prev" aria-label="Previous">&lsaquo;</button>
673 <div class="lightbox-content" id="lightbox-content"></div>
674 <button class="lightbox-nav lightbox-next" aria-label="Next">&rsaquo;</button>
675 </div>
676
677 <footer>
678 <span>&copy; <?= strtolower($name) ?></span>
679 </footer>
680
681 <script>
682 // ── Config ── //
683 const MANIFEST = <?= json_encode($manifest) ?>;
684 const SITE = <?= json_encode(['name' => $site['name'], 'collections' => $site['collections']]) ?>;
685
686 // ── Helpers ── //
687
688 function prettifyName(slug) {
689 return slug
690 .split('-')
691 .map(w => w.charAt(0).toUpperCase() + w.slice(1))
692 .join(' ')
693 .replace(' And ', ' & ');
694 }
695
696 function formatDuration(seconds) {
697 const m = Math.floor(seconds / 60);
698 const s = Math.floor(seconds % 60);
699 return m + ':' + (s < 10 ? '0' : '') + s;
700 }
701
702 // ── Minimal Markdown Parser (JS) ── //
703
704 function parseMarkdown(md) {
705 return md
706 .replace(/^### (.+)$/gm, '<h3>$1</h3>')
707 .replace(/^## (.+)$/gm, '<h2>$1</h2>')
708 .replace(/^# (.+)$/gm, '<h1>$1</h1>')
709 .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
710 .replace(/\*(.+?)\*/g, '<em>$1</em>')
711 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
712 .replace(/\n\n+/g, '</p><p>')
713 .replace(/^/, '<p>')
714 .replace(/$/, '</p>')
715 .replace(/<p>(<h[123]>)/g, '$1')
716 .replace(/(<\/h[123]>)<\/p>/g, '$1')
717 .replace(/<p><\/p>/g, '');
718 }
719
720 // ── View State ── //
721
722 let currentView = 'gallery';
723 let activeCollection = 'all';
724
725 // ── Sidebar ── //
726
727 function renderSidebar() {
728 const ul = document.getElementById('collections');
729 const allLink = ul.querySelector('[data-collection="all"]');
730
731 // Remove old dynamic links
732 ul.querySelectorAll('li:not(:first-child)').forEach(li => li.remove());
733
734 // Use configured order with fallback to manifest collections
735 const order = (SITE.collections && SITE.collections.order) || MANIFEST.collections || [];
736
737 for (const coll of order) {
738 const li = document.createElement('li');
739 const a = document.createElement('a');
740 a.href = '#';
741 a.dataset.collection = coll;
742 a.textContent = prettifyName(coll);
743 if (coll === activeCollection) a.classList.add('active');
744 li.appendChild(a);
745 ul.appendChild(li);
746 }
747
748 // Update All Works active state
749 if (activeCollection === 'all') {
750 allLink.classList.add('active');
751 } else {
752 allLink.classList.remove('active');
753 }
754 }
755
756 // Sidebar click handler (delegated, added once)
757 document.getElementById('collections').addEventListener('click', (e) => {
758 if (e.target.tagName !== 'A') return;
759 e.preventDefault();
760 activeCollection = e.target.dataset.collection;
761 document.querySelectorAll('#collections a').forEach(a => a.classList.remove('active'));
762 e.target.classList.add('active');
763 document.getElementById('about-link').classList.remove('active');
764 document.getElementById('contact-link').classList.remove('active');
765 renderGallery();
766 });
767
768 // ── Gallery ── //
769
770 function getFilteredItems() {
771 const items = MANIFEST.items || [];
772 if (activeCollection === 'all') return items;
773 return items.filter(item => item.collection === activeCollection);
774 }
775
776 function renderGallery() {
777 currentView = 'gallery';
778 const container = document.getElementById('gallery');
779 container.innerHTML = '';
780 container.className = 'masonry';
781
782 const items = getFilteredItems();
783
784 if (items.length === 0) {
785 container.innerHTML = '<p class="loading">No items found</p>';
786 return;
787 }
788
789 items.forEach((item, index) => {
790 const div = document.createElement('div');
791 div.className = 'masonry-item';
792
793 const img = document.createElement('img');
794 img.src = item.thumb;
795 img.alt = (item.name || '').replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
796 img.loading = 'lazy';
797 img.addEventListener('click', () => openLightbox(items, index));
798 div.appendChild(img);
799
800 // Video overlay
801 if (item.type === 'video') {
802 const overlay = document.createElement('div');
803 overlay.className = 'video-overlay';
804 overlay.innerHTML = '&#9654;';
805 overlay.addEventListener('click', (e) => {
806 e.stopPropagation();
807 openLightbox(items, index);
808 });
809 div.appendChild(overlay);
810 }
811
812 // Info icon
813 if (item.hasInfo) {
814 const icon = document.createElement('span');
815 icon.className = 'info-icon';
816 icon.textContent = 'i';
817 icon.addEventListener('click', (e) => {
818 e.stopPropagation();
819 showInfoPage(item);
820 });
821 div.appendChild(icon);
822 }
823
824 container.appendChild(div);
825 });
826 }
827
828 // ── About Page ── //
829
830 function showAboutPage() {
831 currentView = 'about';
832
833 // Clear sidebar active
834 document.querySelectorAll('#collections a').forEach(a => a.classList.remove('active'));
835 document.getElementById('about-link').classList.add('active');
836 document.getElementById('contact-link').classList.remove('active');
837
838 const container = document.getElementById('gallery');
839 container.className = '';
840 container.innerHTML = '';
841
842 const template = document.getElementById('about-content');
843 const clone = template.content.cloneNode(true);
844 container.appendChild(clone);
845 }
846
847 // ── Contact Page ── //
848
849 function showContactPage() {
850 currentView = 'contact';
851
852 document.querySelectorAll('#collections a').forEach(a => a.classList.remove('active'));
853 document.getElementById('about-link').classList.remove('active');
854 document.getElementById('contact-link').classList.add('active');
855
856 const container = document.getElementById('gallery');
857 container.className = '';
858 container.innerHTML = '';
859
860 const template = document.getElementById('contact-content');
861 const clone = template.content.cloneNode(true);
862 container.appendChild(clone);
863 }
864
865 // ── Info Page ── //
866
867 async function showInfoPage(item) {
868 const container = document.getElementById('gallery');
869 container.className = '';
870 container.innerHTML = '<p class="loading">Loading...</p>';
871 currentView = 'info';
872
873 try {
874 const res = await fetch(item.mdUrl, { cache: 'no-cache' });
875 if (!res.ok) throw new Error('Not found');
876 const md = await res.text();
877
878 container.innerHTML = '';
879 const page = document.createElement('div');
880 page.className = 'info-page';
881
882 const back = document.createElement('a');
883 back.href = '#';
884 back.className = 'back-link';
885 back.textContent = '\u2190 back to gallery';
886 back.addEventListener('click', (e) => {
887 e.preventDefault();
888 renderGallery();
889 });
890 page.appendChild(back);
891
892 const image = document.createElement('img');
893 image.src = item.full;
894 image.alt = (item.name || '').replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
895 page.appendChild(image);
896
897 const content = document.createElement('div');
898 content.className = 'info-content';
899 content.innerHTML = parseMarkdown(md);
900 page.appendChild(content);
901
902 container.appendChild(page);
903 } catch {
904 container.innerHTML = '<p class="loading">Could not load info</p>';
905 }
906 }
907
908 // ── Header Click Handlers ── //
909
910 document.getElementById('logo-link').addEventListener('click', (e) => {
911 e.preventDefault();
912 activeCollection = 'all';
913 document.querySelectorAll('#collections a').forEach(a => a.classList.remove('active'));
914 const allLink = document.querySelector('[data-collection="all"]');
915 if (allLink) allLink.classList.add('active');
916 document.getElementById('about-link').classList.remove('active');
917 document.getElementById('contact-link').classList.remove('active');
918 renderGallery();
919 });
920
921 document.getElementById('about-link').addEventListener('click', (e) => {
922 e.preventDefault();
923 showAboutPage();
924 });
925
926 document.getElementById('contact-link').addEventListener('click', (e) => {
927 e.preventDefault();
928 showContactPage();
929 });
930
931 // ── Lightbox ── //
932
933 let lightboxItems = [];
934 let lightboxIndex = 0;
935
936 function stopCurrentMedia() {
937 const video = document.querySelector('#lightbox-content video');
938 if (video) {
939 video.pause();
940 video.src = '';
941 }
942 }
943
944 function renderLightboxItem() {
945 const container = document.getElementById('lightbox-content');
946 stopCurrentMedia();
947 container.innerHTML = '';
948
949 const item = lightboxItems[lightboxIndex];
950 if (!item) return;
951
952 if (item.type === 'video') {
953 renderVideoPlayer(container, item);
954 } else {
955 const img = document.createElement('img');
956 img.src = item.full;
957 img.alt = (item.name || '').replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
958 container.appendChild(img);
959 }
960 }
961
962 function openLightbox(items, index) {
963 lightboxItems = items;
964 lightboxIndex = index;
965 renderLightboxItem();
966 document.getElementById('lightbox').classList.add('open');
967 }
968
969 function closeLightbox() {
970 stopCurrentMedia();
971 document.getElementById('lightbox').classList.remove('open');
972 document.getElementById('lightbox').classList.remove('theater-mode');
973 }
974
975 function navigateLightbox(delta) {
976 if (lightboxItems.length === 0) return;
977 lightboxIndex = (lightboxIndex + delta + lightboxItems.length) % lightboxItems.length;
978 renderLightboxItem();
979 }
980
981 // Lightbox event listeners
982 document.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
983 document.querySelector('.lightbox-prev').addEventListener('click', () => navigateLightbox(-1));
984 document.querySelector('.lightbox-next').addEventListener('click', () => navigateLightbox(1));
985
986 document.getElementById('lightbox').addEventListener('click', (e) => {
987 if (e.target === e.currentTarget) closeLightbox();
988 });
989
990 document.addEventListener('keydown', (e) => {
991 const lb = document.getElementById('lightbox');
992 if (!lb.classList.contains('open')) return;
993 if (e.key === 'Escape') closeLightbox();
994 if (e.key === 'ArrowLeft') navigateLightbox(-1);
995 if (e.key === 'ArrowRight') navigateLightbox(1);
996 });
997
998 // ── Custom Video Player ── //
999
1000 function renderVideoPlayer(container, item) {
1001 const wrapper = document.createElement('div');
1002 wrapper.className = 'video-player';
1003
1004 // Video element
1005 const video = document.createElement('video');
1006 video.src = item.full;
1007 video.preload = 'metadata';
1008 video.addEventListener('click', () => {
1009 if (video.paused) video.play();
1010 else video.pause();
1011 });
1012 wrapper.appendChild(video);
1013
1014 // Buffering spinner
1015 const buffering = document.createElement('div');
1016 buffering.className = 'video-buffering';
1017 buffering.innerHTML = '<div class="spinner"></div>';
1018 wrapper.appendChild(buffering);
1019
1020 video.addEventListener('waiting', () => buffering.classList.add('visible'));
1021 video.addEventListener('canplay', () => buffering.classList.remove('visible'));
1022 video.addEventListener('playing', () => buffering.classList.remove('visible'));
1023
1024 // Progress bar
1025 const progressWrap = document.createElement('div');
1026 progressWrap.className = 'video-progress-wrap';
1027
1028 const bufferBar = document.createElement('div');
1029 bufferBar.className = 'video-progress-buffer';
1030 progressWrap.appendChild(bufferBar);
1031
1032 const progressBar = document.createElement('div');
1033 progressBar.className = 'video-progress-bar';
1034 progressWrap.appendChild(progressBar);
1035
1036 wrapper.appendChild(progressWrap);
1037
1038 // Seeking via mouse
1039 let seeking = false;
1040
1041 function seekTo(e) {
1042 const rect = progressWrap.getBoundingClientRect();
1043 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
1044 if (video.duration) video.currentTime = ratio * video.duration;
1045 }
1046
1047 progressWrap.addEventListener('mousedown', (e) => {
1048 seeking = true;
1049 seekTo(e);
1050 });
1051
1052 document.addEventListener('mousemove', (e) => {
1053 if (seeking) seekTo(e);
1054 });
1055
1056 document.addEventListener('mouseup', () => {
1057 seeking = false;
1058 });
1059
1060 // Seeking via touch
1061 progressWrap.addEventListener('touchstart', (e) => {
1062 seeking = true;
1063 const touch = e.touches[0];
1064 const rect = progressWrap.getBoundingClientRect();
1065 const ratio = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
1066 if (video.duration) video.currentTime = ratio * video.duration;
1067 });
1068
1069 progressWrap.addEventListener('touchmove', (e) => {
1070 if (!seeking) return;
1071 const touch = e.touches[0];
1072 const rect = progressWrap.getBoundingClientRect();
1073 const ratio = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
1074 if (video.duration) video.currentTime = ratio * video.duration;
1075 });
1076
1077 progressWrap.addEventListener('touchend', () => {
1078 seeking = false;
1079 });
1080
1081 // Controls bar
1082 const controls = document.createElement('div');
1083 controls.className = 'video-controls';
1084
1085 // Play/Pause button
1086 const playBtn = document.createElement('button');
1087 playBtn.className = 'vc-btn vc-play';
1088 playBtn.innerHTML = '&#9654;';
1089 playBtn.addEventListener('click', () => {
1090 if (video.paused) video.play();
1091 else video.pause();
1092 });
1093 controls.appendChild(playBtn);
1094
1095 // Skip -10s
1096 const skipBack = document.createElement('button');
1097 skipBack.className = 'vc-btn vc-skip';
1098 skipBack.textContent = '-10s';
1099 skipBack.addEventListener('click', () => {
1100 video.currentTime = Math.max(0, video.currentTime - 10);
1101 });
1102 controls.appendChild(skipBack);
1103
1104 // Skip +10s
1105 const skipFwd = document.createElement('button');
1106 skipFwd.className = 'vc-btn vc-skip';
1107 skipFwd.textContent = '+10s';
1108 skipFwd.addEventListener('click', () => {
1109 video.currentTime = Math.min(video.duration || 0, video.currentTime + 10);
1110 });
1111 controls.appendChild(skipFwd);
1112
1113 // Time display
1114 const timeDisplay = document.createElement('span');
1115 timeDisplay.className = 'vc-time';
1116 timeDisplay.textContent = '0:00 / 0:00';
1117 controls.appendChild(timeDisplay);
1118
1119 // Spacer
1120 const spacer = document.createElement('span');
1121 spacer.className = 'vc-spacer';
1122 controls.appendChild(spacer);
1123
1124 // Volume toggle
1125 const volBtn = document.createElement('button');
1126 volBtn.className = 'vc-btn';
1127 volBtn.innerHTML = '&#128264;';
1128 volBtn.addEventListener('click', () => {
1129 video.muted = !video.muted;
1130 volBtn.innerHTML = video.muted ? '&#128263;' : '&#128264;';
1131 });
1132 controls.appendChild(volBtn);
1133
1134 // Theater mode toggle
1135 const theaterBtn = document.createElement('button');
1136 theaterBtn.className = 'vc-btn';
1137 theaterBtn.innerHTML = '&#9635;';
1138 theaterBtn.addEventListener('click', () => {
1139 document.getElementById('lightbox').classList.toggle('theater-mode');
1140 });
1141 controls.appendChild(theaterBtn);
1142
1143 // Fullscreen toggle
1144 const fsBtn = document.createElement('button');
1145 fsBtn.className = 'vc-btn';
1146 fsBtn.innerHTML = '&#x26F6;';
1147 fsBtn.addEventListener('click', () => {
1148 if (document.fullscreenElement) {
1149 document.exitFullscreen();
1150 } else {
1151 wrapper.requestFullscreen().catch(() => {});
1152 }
1153 });
1154 controls.appendChild(fsBtn);
1155
1156 wrapper.appendChild(controls);
1157
1158 // Video events
1159 video.addEventListener('play', () => {
1160 playBtn.innerHTML = '&#9646;&#9646;';
1161 });
1162
1163 video.addEventListener('pause', () => {
1164 playBtn.innerHTML = '&#9654;';
1165 });
1166
1167 video.addEventListener('timeupdate', () => {
1168 if (!video.duration) return;
1169 const pct = (video.currentTime / video.duration) * 100;
1170 progressBar.style.width = pct + '%';
1171 timeDisplay.textContent = formatDuration(video.currentTime) + ' / ' + formatDuration(video.duration);
1172 });
1173
1174 video.addEventListener('progress', () => {
1175 if (!video.duration || !video.buffered.length) return;
1176 const end = video.buffered.end(video.buffered.length - 1);
1177 bufferBar.style.width = (end / video.duration) * 100 + '%';
1178 });
1179
1180 container.appendChild(wrapper);
1181 }
1182
1183 // ── Touch Swipe for Lightbox ── //
1184
1185 let touchStartX = 0;
1186 let touchStartY = 0;
1187
1188 document.getElementById('lightbox').addEventListener('touchstart', (e) => {
1189 touchStartX = e.touches[0].clientX;
1190 touchStartY = e.touches[0].clientY;
1191 });
1192
1193 document.getElementById('lightbox').addEventListener('touchend', (e) => {
1194 const dx = e.changedTouches[0].clientX - touchStartX;
1195 const dy = e.changedTouches[0].clientY - touchStartY;
1196
1197 // Only horizontal swipes with >50px threshold
1198 if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
1199 if (dx > 0) navigateLightbox(-1);
1200 else navigateLightbox(1);
1201 }
1202 });
1203
1204 // ── Init ── //
1205
1206 function init() {
1207 renderSidebar();
1208 renderGallery();
1209 }
1210
1211 init();
1212 </script>
1213
1214</body>
1215</html>