summaryrefslogtreecommitdiffstats
path: root/assets/js/photo-utils.js
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-04-10 11:29:00 +0200
committerDanilo M. <danix@danix.xyz>2026-04-10 11:29:00 +0200
commitc42150058196f5affad5c6c590e99dd2fc7321c3 (patch)
treecb0a7ad297128a43d32111e403959491573b6ace /assets/js/photo-utils.js
parentd51e4ef7dcd8609cd008a803f9d51674ac3d3ed2 (diff)
downloaddanixxyz-theme-c42150058196f5affad5c6c590e99dd2fc7321c3.tar.gz
danixxyz-theme-c42150058196f5affad5c6c590e99dd2fc7321c3.zip
feat: complete Hugo theme implementation from mockups
Transform all production-ready mockup files into a fully functional Hugo theme with all design patterns, components, and interactivity. Implements the complete plan: token alignment, global shell, homepage, articles section, single article views, photo gallery, static pages, and 404 page. Changes: - Phase 0: Token alignment (--color-* → --type-*, add spacing/z-index/timing scales) - Phase 1a: Global shell (baseof.html, hamburger menu, theme toggle, matrix rain) - Phase 1b: Homepage (hero layout, glitch/typing/scroll-reveal effects) - Phase 1c: Articles section (timeline layout, filter system, featured cards) - Phase 1d: Single article (meta bar, share sidebar, footer nav, progress bar) - Phase 1e: Photo gallery (lightbox, grid layout, shortcode updates) - Phase 1f: Static pages (about/contact page layout) - Phase 1g: 404 page (standalone HTML, quote randomization, recent articles) New files: - 6 CSS components: hamburger, article-hero, share-sidebar, timeline, lightbox, 404 - 8 JS modules: hamburger, glitch, typing, scroll-reveal, share-sidebar, lightbox, 404, photo-utils - 6 template partials: article-single, featured-card, photo-article, share-sidebar, static-page, timeline-item - 1 layout: 404.html (standalone) Updated: - All CSS variables with comprehensive token system - All JS modules integrated into main.js - All shortcodes (gallery, gal-img) for lightbox compatibility - All layout files (baseof, home, section, page) with new dispatching logic Verified: Hugo build succeeds with 21 pages, no errors. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Diffstat (limited to 'assets/js/photo-utils.js')
-rw-r--r--assets/js/photo-utils.js476
1 files changed, 476 insertions, 0 deletions
diff --git a/assets/js/photo-utils.js b/assets/js/photo-utils.js
new file mode 100644
index 0000000..32ede76
--- /dev/null
+++ b/assets/js/photo-utils.js
@@ -0,0 +1,476 @@
+(function() {
+ 'use strict';
+
+ // Internal state
+ let currentPhotoIndex = 0;
+ let photosData = [];
+ let isOpen = false;
+ let exifData = {};
+ let options = {
+ swipeEnabled: true,
+ keyboardEnabled: true,
+ extractEXIF: true,
+ onOpen: null,
+ onClose: null,
+ };
+
+ // Lightbox DOM elements (created on first init)
+ let lightboxModal = null;
+ let lightboxImage = null;
+ let lightboxCaption = null;
+ let lightboxSidebar = null;
+
+ /**
+ * Initialize lightbox for a photo collection
+ * @param {string} containerSelector - CSS selector for photo container
+ * @param {Array} photos - Array of photo objects: {src, alt, caption, location}
+ * @param {Object} opts - Options: {swipeEnabled, keyboardEnabled, extractEXIF, onOpen, onClose}
+ * @returns {Object} - {openLightbox, closeLightbox}
+ */
+ function initLightbox(containerSelector, photos, opts = {}) {
+ photosData = photos;
+ options = { ...options, ...opts };
+
+ // Merge options with defaults
+ const container = document.querySelector(containerSelector);
+ if (!container) {
+ console.error('Photo container not found:', containerSelector);
+ return { openLightbox: () => {}, closeLightbox: () => {} };
+ }
+
+ // Attach click listeners to all figures in the container
+ const figures = container.querySelectorAll('figure[data-photo-index]');
+ figures.forEach((figure) => {
+ figure.addEventListener('click', () => {
+ const index = parseInt(figure.dataset.photoIndex, 10);
+ openLightbox(index);
+ });
+ });
+
+ // Return public API
+ return {
+ openLightbox,
+ closeLightbox,
+ };
+ }
+
+ /**
+ * Open lightbox to a specific photo
+ * @param {number} photoIndex - 0-based index into photosData array
+ */
+ function openLightbox(photoIndex) {
+ if (photoIndex < 0 || photoIndex >= photosData.length) {
+ console.warn('Invalid photo index:', photoIndex);
+ return;
+ }
+
+ currentPhotoIndex = photoIndex;
+ isOpen = true;
+
+ // Create lightbox DOM if it doesn't exist
+ if (!lightboxModal) {
+ createLightboxDOM();
+ }
+
+ // Populate lightbox with current photo
+ updateLightboxContent();
+
+ // Show modal
+ lightboxModal.classList.add('visible');
+ document.body.style.overflow = 'hidden';
+
+ // Extract EXIF if enabled
+ if (options.extractEXIF) {
+ extractEXIFForCurrentPhoto();
+ }
+
+ // Attach event listeners
+ attachEventListeners();
+
+ // Callback
+ if (options.onOpen) {
+ options.onOpen(photoIndex);
+ }
+ }
+
+ /**
+ * Close lightbox
+ */
+ function closeLightbox() {
+ if (!isOpen || !lightboxModal) return;
+
+ isOpen = false;
+ lightboxModal.classList.remove('visible');
+ document.body.style.overflow = '';
+
+ // Detach event listeners
+ detachEventListeners();
+
+ // Callback
+ if (options.onClose) {
+ options.onClose();
+ }
+ }
+
+ /**
+ * Create lightbox HTML structure and inject into DOM
+ */
+ function createLightboxDOM() {
+ lightboxModal = document.createElement('div');
+ lightboxModal.className = 'photo-lightbox';
+
+ lightboxModal.innerHTML = `
+ <div class="photo-lightbox-backdrop"></div>
+ <div class="photo-lightbox-container">
+ <button class="photo-lightbox-close" aria-label="Close lightbox">×</button>
+
+ <button class="photo-lightbox-prev" aria-label="Previous photo">←</button>
+
+ <div class="photo-lightbox-content">
+ <img class="photo-lightbox-image" src="" alt="">
+ <div class="photo-lightbox-caption"></div>
+ </div>
+
+ <button class="photo-lightbox-next" aria-label="Next photo">→</button>
+
+ <div class="photo-lightbox-sidebar">
+ <div class="photo-lightbox-sidebar-content"></div>
+ </div>
+ </div>
+ `;
+
+ document.body.appendChild(lightboxModal);
+
+ // Cache element references
+ lightboxImage = lightboxModal.querySelector('.photo-lightbox-image');
+ lightboxCaption = lightboxModal.querySelector('.photo-lightbox-caption');
+ lightboxSidebar = lightboxModal.querySelector('.photo-lightbox-sidebar-content');
+
+ // Attach close button and backdrop click
+ lightboxModal.querySelector('.photo-lightbox-close').addEventListener('click', closeLightbox);
+ lightboxModal.querySelector('.photo-lightbox-backdrop').addEventListener('click', closeLightbox);
+
+ // Attach navigation buttons
+ lightboxModal.querySelector('.photo-lightbox-prev').addEventListener('click', () => navigate(-1));
+ lightboxModal.querySelector('.photo-lightbox-next').addEventListener('click', () => navigate(1));
+ }
+
+ /**
+ * Update lightbox content with current photo
+ */
+ function updateLightboxContent() {
+ const photo = photosData[currentPhotoIndex];
+ if (!photo) return;
+
+ lightboxImage.src = photo.src;
+ lightboxImage.alt = photo.alt || '';
+
+ // Caption
+ if (photo.caption) {
+ lightboxCaption.textContent = photo.caption;
+ lightboxCaption.style.display = 'block';
+ } else {
+ lightboxCaption.style.display = 'none';
+ }
+
+ // Update prev/next button states
+ const prevBtn = lightboxModal.querySelector('.photo-lightbox-prev');
+ const nextBtn = lightboxModal.querySelector('.photo-lightbox-next');
+ prevBtn.disabled = currentPhotoIndex === 0;
+ nextBtn.disabled = currentPhotoIndex === photosData.length - 1;
+ }
+
+ /**
+ * Navigate to previous or next photo
+ * @param {number} direction - -1 for prev, 1 for next
+ */
+ function navigate(direction) {
+ const newIndex = currentPhotoIndex + direction;
+ if (newIndex >= 0 && newIndex < photosData.length) {
+ currentPhotoIndex = newIndex;
+ updateLightboxContent();
+
+ // Reset EXIF data and re-extract if enabled
+ exifData = {};
+ if (options.extractEXIF) {
+ extractEXIFForCurrentPhoto();
+ }
+ }
+ }
+
+ /**
+ * Extract EXIF data from current photo image element
+ */
+ function extractEXIFForCurrentPhoto() {
+ extractEXIF(lightboxImage).then((data) => {
+ exifData = data;
+ updateSidebarContent();
+ });
+ }
+
+ /**
+ * Update sidebar with EXIF and location data
+ */
+ function updateSidebarContent() {
+ const photo = photosData[currentPhotoIndex];
+ let sidebarHTML = '';
+
+ // Always show location if provided
+ if (photo.location) {
+ sidebarHTML += `
+ <div class="photo-metadata-field">
+ <div class="photo-metadata-label">Location</div>
+ <div class="photo-metadata-value">${photo.location}</div>
+ </div>
+ `;
+ }
+
+ // Show EXIF fields if available
+ if (Object.keys(exifData).length > 0) {
+ sidebarHTML += `
+ <div class="photo-metadata-divider"></div>
+ <div class="photo-metadata-label">Camera</div>
+ `;
+
+ if (exifData.camera) {
+ sidebarHTML += `
+ <div class="photo-metadata-value">${exifData.camera}</div>
+ `;
+ }
+ if (exifData.lens) {
+ sidebarHTML += `
+ <div class="photo-metadata-field">
+ <div class="photo-metadata-label">Lens</div>
+ <div class="photo-metadata-value">${exifData.lens}</div>
+ </div>
+ `;
+ }
+ if (exifData.focalLength) {
+ sidebarHTML += `
+ <div class="photo-metadata-field">
+ <div class="photo-metadata-label">Focal Length</div>
+ <div class="photo-metadata-value">${exifData.focalLength}</div>
+ </div>
+ `;
+ }
+ if (exifData.aperture) {
+ sidebarHTML += `
+ <div class="photo-metadata-field">
+ <div class="photo-metadata-label">Aperture</div>
+ <div class="photo-metadata-value">${exifData.aperture}</div>
+ </div>
+ `;
+ }
+ if (exifData.shutterSpeed) {
+ sidebarHTML += `
+ <div class="photo-metadata-field">
+ <div class="photo-metadata-label">Shutter Speed</div>
+ <div class="photo-metadata-value">${exifData.shutterSpeed}</div>
+ </div>
+ `;
+ }
+ if (exifData.iso) {
+ sidebarHTML += `
+ <div class="photo-metadata-field">
+ <div class="photo-metadata-label">ISO</div>
+ <div class="photo-metadata-value">${exifData.iso}</div>
+ </div>
+ `;
+ }
+ if (exifData.dateTaken) {
+ sidebarHTML += `
+ <div class="photo-metadata-field">
+ <div class="photo-metadata-label">Date Taken</div>
+ <div class="photo-metadata-value">${exifData.dateTaken}</div>
+ </div>
+ `;
+ }
+ }
+
+ // Show or hide sidebar based on whether there's content
+ const sidebarContainer = lightboxModal.querySelector('.photo-lightbox-sidebar');
+ if (sidebarHTML) {
+ lightboxSidebar.innerHTML = sidebarHTML;
+ sidebarContainer.classList.add('visible');
+ } else {
+ sidebarContainer.classList.remove('visible');
+ }
+ }
+
+ /**
+ * Extract EXIF data from an image element using exif-js library
+ * @param {HTMLImageElement} imageElement - Image to read EXIF from
+ * @returns {Promise} - Resolves to {camera, lens, iso, aperture, shutterSpeed, focalLength, dateTaken}
+ */
+ function extractEXIF(imageElement) {
+ return new Promise((resolve) => {
+ // If exif-js is not loaded, return empty object
+ if (typeof EXIF === 'undefined') {
+ console.warn('exif-js library not loaded');
+ resolve({});
+ return;
+ }
+
+ EXIF.getData(imageElement, function() {
+ const data = {
+ camera: EXIF.getTag(this, 'Model'),
+ lens: EXIF.getTag(this, 'LensModel'),
+ iso: EXIF.getTag(this, 'ISOSpeedRatings'),
+ aperture: formatAperture(EXIF.getTag(this, 'FNumber')),
+ shutterSpeed: formatShutterSpeed(EXIF.getTag(this, 'ExposureTime')),
+ focalLength: formatFocalLength(EXIF.getTag(this, 'FocalLength')),
+ dateTaken: EXIF.getTag(this, 'DateTime'),
+ };
+
+ // Filter out undefined values
+ Object.keys(data).forEach(key => {
+ if (data[key] === undefined) {
+ delete data[key];
+ }
+ });
+
+ resolve(data);
+ });
+ });
+ }
+
+ /**
+ * Format aperture value (f-number)
+ */
+ function formatAperture(value) {
+ if (!value) return null;
+ if (typeof value === 'object' && value.numerator !== undefined) {
+ return `f/${(value.numerator / value.denominator).toFixed(1)}`;
+ }
+ return `f/${value}`;
+ }
+
+ /**
+ * Format shutter speed (exposure time)
+ */
+ function formatShutterSpeed(value) {
+ if (!value) return null;
+ if (typeof value === 'object' && value.numerator !== undefined) {
+ const speed = value.numerator / value.denominator;
+ if (speed >= 1) {
+ return `${speed.toFixed(1)}s`;
+ }
+ return `1/${Math.round(1 / speed)}`;
+ }
+ return value;
+ }
+
+ /**
+ * Format focal length
+ */
+ function formatFocalLength(value) {
+ if (!value) return null;
+ if (typeof value === 'object' && value.numerator !== undefined) {
+ return `${(value.numerator / value.denominator).toFixed(0)}mm`;
+ }
+ return `${value}mm`;
+ }
+
+ /**
+ * Attach event listeners (swipe, keyboard, etc.)
+ */
+ function attachEventListeners() {
+ if (options.swipeEnabled) {
+ attachSwipeListeners();
+ }
+ if (options.keyboardEnabled) {
+ attachKeyboardListeners();
+ }
+ }
+
+ /**
+ * Detach event listeners to prevent memory leaks
+ */
+ function detachEventListeners() {
+ if (options.swipeEnabled) {
+ detachSwipeListeners();
+ }
+ if (options.keyboardEnabled) {
+ detachKeyboardListeners();
+ }
+ }
+
+ /**
+ * Touch swipe event listeners
+ */
+ let swipeStartX = 0;
+ let swipeStartY = 0;
+
+ function onTouchStart(e) {
+ swipeStartX = e.touches[0].clientX;
+ swipeStartY = e.touches[0].clientY;
+ }
+
+ function onTouchEnd(e) {
+ const swipeEndX = e.changedTouches[0].clientX;
+ const swipeEndY = e.changedTouches[0].clientY;
+ const diffX = swipeEndX - swipeStartX;
+ const diffY = swipeEndY - swipeStartY;
+
+ // Only consider horizontal swipe if delta-x > delta-y
+ if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
+ if (diffX > 0) {
+ // Swiped right: go to previous
+ navigate(-1);
+ } else {
+ // Swiped left: go to next
+ navigate(1);
+ }
+ }
+ }
+
+ function attachSwipeListeners() {
+ lightboxModal.addEventListener('touchstart', onTouchStart, false);
+ lightboxModal.addEventListener('touchend', onTouchEnd, false);
+ }
+
+ function detachSwipeListeners() {
+ lightboxModal.removeEventListener('touchstart', onTouchStart, false);
+ lightboxModal.removeEventListener('touchend', onTouchEnd, false);
+ }
+
+ /**
+ * Keyboard event listeners
+ */
+ function onKeyDown(e) {
+ if (!isOpen) return;
+
+ switch (e.key) {
+ case 'ArrowLeft':
+ navigate(-1);
+ e.preventDefault();
+ break;
+ case 'ArrowRight':
+ navigate(1);
+ e.preventDefault();
+ break;
+ case 'Escape':
+ closeLightbox();
+ e.preventDefault();
+ break;
+ default:
+ break;
+ }
+ }
+
+ function attachKeyboardListeners() {
+ document.addEventListener('keydown', onKeyDown);
+ }
+
+ function detachKeyboardListeners() {
+ document.removeEventListener('keydown', onKeyDown);
+ }
+
+ // Expose public API
+ window.PhotoUtils = {
+ initLightbox,
+ extractEXIF,
+ openLightbox,
+ closeLightbox,
+ };
+})();