/** * Image Gallery Hydration for MkDocs * * Automatically converts .photo-block, .photo-card-block, and .photo-album-block * placeholders into actual images/galleries when landing pages with photo blocks * are exported to MkDocs. * * Uses the same MEDIA_API_URL and PUBLIC_URL globals as video-player.js * (injected by hooks/env_config_hook.py). */ (function() { 'use strict'; // Configuration — same globals as video-player.js var MEDIA_API_URL = window.MEDIA_API_URL || 'http://localhost:4100'; var PUBLIC_URL = window.PUBLIC_URL || 'http://localhost:3000'; /** * Escape HTML to prevent XSS */ function escapeHtml(unsafe) { return String(unsafe) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Format view count (e.g. 1200 -> "1.2K views") */ function formatViewCount(count) { if (!count || count <= 0) return '0 views'; if (count === 1) return '1 view'; if (count < 1000) return count + ' views'; if (count < 1000000) return (count / 1000).toFixed(1).replace(/\.0$/, '') + 'K views'; return (count / 1000000).toFixed(1).replace(/\.0$/, '') + 'M views'; } /** * Get gallery URL for a photo */ function getGalleryUrl(photoId) { return PUBLIC_URL + '/gallery?expanded=photo-' + photoId; } /** * Show error state on an element */ function showError(el, message) { el.innerHTML = '
' + '' + '' + '' + '

' + escapeHtml(message) + '

' + '
'; } /** * Fetch photo metadata from Media API */ async function fetchPhotoMetadata(photoId) { try { var response = await fetch(MEDIA_API_URL + '/api/public/photos/' + photoId); if (!response.ok) { throw new Error('Failed to fetch photo ' + photoId + ': ' + response.statusText); } return await response.json(); } catch (error) { console.error('Error fetching photo ' + photoId + ' metadata:', error); return null; } } /** * Fetch album data from Media API */ async function fetchAlbumData(albumId) { try { var response = await fetch(MEDIA_API_URL + '/api/public/albums/' + albumId); if (!response.ok) { throw new Error('Failed to fetch album ' + albumId + ': ' + response.statusText); } return await response.json(); } catch (error) { console.error('Error fetching album ' + albumId + ':', error); return null; } } // ─── Photo Block ───────────────────────────────────────────────────── /** * Render a single photo block. * Replaces placeholder with an tag, optional caption, and gallery link. */ async function renderPhotoBlock(el) { var photoId = parseInt(el.dataset.photoId, 10); var size = el.dataset.size || 'large'; var caption = el.dataset.caption || ''; var linkToGallery = el.dataset.linkToGallery !== 'false'; var alignment = el.dataset.alignment || 'center'; if (!photoId || isNaN(photoId)) { showError(el, 'Invalid photo ID'); return; } // Show loading state el.innerHTML = '
Loading photo...
'; // Fetch metadata for alt text and title var metadata = await fetchPhotoMetadata(photoId); if (!metadata) { showError(el, 'Photo not found (ID: ' + photoId + ')'); return; } var imgUrl = MEDIA_API_URL + '/api/public/photos/' + photoId + '/image?size=' + size; var altText = escapeHtml(metadata.title || 'Photo ' + photoId); // Build image element var img = document.createElement('img'); img.src = imgUrl; img.alt = altText; img.title = altText; img.style.width = '100%'; img.style.height = 'auto'; img.style.borderRadius = '8px'; img.style.display = 'block'; // Clear and rebuild el.innerHTML = ''; el.style.textAlign = alignment; if (linkToGallery) { var link = document.createElement('a'); link.href = getGalleryUrl(photoId); link.target = '_blank'; link.appendChild(img); el.appendChild(link); } else { el.appendChild(img); } // Add caption if provided (from data attribute or metadata) var captionText = caption || metadata.description || ''; if (captionText) { var captionEl = document.createElement('div'); captionEl.className = 'photo-caption'; captionEl.textContent = captionText; el.appendChild(captionEl); } console.log('Rendered photo block for photo ' + photoId); } // ─── Photo Card ────────────────────────────────────────────────────── /** * Hydrate a photo card block: * 1. Fix thumbnail URL using MEDIA_API_URL * 2. Fetch live metadata (view count) * 3. Mark as hydrated */ async function hydratePhotoCard(cardEl) { var photoId = parseInt(cardEl.dataset.photoId, 10); if (!photoId || isNaN(photoId)) return; // 1. Fix thumbnail URL var img = cardEl.querySelector('img'); if (img) { var correctThumbUrl = MEDIA_API_URL + '/api/public/photos/' + photoId + '/thumbnail?v=' + Date.now(); img.src = correctThumbUrl; img.onerror = function() { img.style.display = 'none'; }; } // 2. Fetch live metadata to update views try { var metadata = await fetchPhotoMetadata(photoId); if (metadata) { // Update view count if rendered in card var viewsEl = cardEl.querySelector('[data-role="views"]'); if (viewsEl && metadata.viewCount !== undefined) { viewsEl.textContent = formatViewCount(metadata.viewCount); } } } catch (e) { // Non-critical } // 3. Fix "View →" link to use correct gallery URL var link = cardEl.querySelector('a'); if (link) { link.href = getGalleryUrl(photoId); link.target = '_blank'; } // 4. Replace "View →" text with gallery button var infoBar = cardEl.querySelector('a > div:last-child'); if (infoBar) { var flexRow = infoBar.querySelector('div:last-child'); if (flexRow) { var spans = flexRow.querySelectorAll('span'); var viewSpan = null; for (var i = 0; i < spans.length; i++) { if (spans[i].textContent.indexOf('View') !== -1) { viewSpan = spans[i]; break; } } if (viewSpan) { var galleryLink = document.createElement('a'); galleryLink.href = getGalleryUrl(photoId); galleryLink.target = '_blank'; galleryLink.innerHTML = '📷 Gallery'; galleryLink.className = 'photo-card-gallery-btn'; galleryLink.style.cssText = 'background:#43cea2;border:none;color:#fff;font-size:12px;font-weight:600;padding:5px 14px;border-radius:4px;cursor:pointer;transition:background 0.15s;text-decoration:none;display:inline-block;'; galleryLink.title = 'Open in gallery'; galleryLink.onmouseenter = function() { galleryLink.style.background = '#5dd8b0'; }; galleryLink.onmouseleave = function() { galleryLink.style.background = '#43cea2'; }; galleryLink.addEventListener('click', function(ev) { ev.stopPropagation(); }); viewSpan.replaceWith(galleryLink); } } } // Mark as hydrated cardEl.dataset.hydrated = 'true'; console.log('Hydrated photo card for photo ' + photoId); } // ─── Photo Album ───────────────────────────────────────────────────── /** * Render a photo album block. * Fetches album data and builds a responsive grid of thumbnails. */ async function renderAlbumBlock(el) { var albumId = parseInt(el.dataset.albumId, 10); var columns = parseInt(el.dataset.columns, 10) || 3; var maxPhotos = parseInt(el.dataset.maxPhotos, 10) || 12; var showTitle = el.dataset.showTitle !== 'false'; if (!albumId || isNaN(albumId)) { showError(el, 'Invalid album ID'); return; } // Show loading state el.innerHTML = '
Loading album...
'; // Fetch album data var album = await fetchAlbumData(albumId); if (!album) { showError(el, 'Album not found (ID: ' + albumId + ')'); return; } // Build album content el.innerHTML = ''; // Album title if (showTitle && album.title) { var titleEl = document.createElement('h3'); titleEl.className = 'photo-album-title'; titleEl.textContent = album.title; el.appendChild(titleEl); } // Photo grid var photos = (album.photos || []).slice(0, maxPhotos); if (photos.length === 0) { el.innerHTML += '
This album has no photos yet.
'; return; } var grid = document.createElement('div'); grid.className = 'photo-album-grid cols-' + columns; for (var i = 0; i < photos.length; i++) { var photo = photos[i]; var cell = document.createElement('div'); cell.className = 'photo-album-cell'; var cellLink = document.createElement('a'); cellLink.href = getGalleryUrl(photo.id); cellLink.target = '_blank'; cellLink.title = photo.title || 'Photo ' + photo.id; var cellImg = document.createElement('img'); cellImg.src = MEDIA_API_URL + '/api/public/photos/' + photo.id + '/thumbnail'; cellImg.alt = photo.title || 'Photo ' + photo.id; cellImg.loading = 'lazy'; cellLink.appendChild(cellImg); cell.appendChild(cellLink); grid.appendChild(cell); } el.appendChild(grid); // "View full album" footer var footer = document.createElement('div'); footer.className = 'photo-album-footer'; var footerLink = document.createElement('a'); footerLink.href = PUBLIC_URL + '/gallery?album=' + albumId; footerLink.target = '_blank'; footerLink.textContent = 'View full album \u2192'; footer.appendChild(footerLink); el.appendChild(footer); console.log('Rendered album block for album ' + albumId + ' (' + photos.length + ' photos)'); } // ─── Photo Album Carousel ────────────────────────────────────────── /** * Render a photo album carousel. * Fetches album data and builds a swipeable slideshow with navigation. */ async function renderCarouselBlock(el) { var albumId = parseInt(el.dataset.albumId, 10); var maxPhotos = parseInt(el.dataset.maxPhotos, 10) || 20; var showTitle = el.dataset.showTitle !== 'false'; var autoPlayEnabled = el.dataset.autoPlay === 'true'; if (!albumId || isNaN(albumId)) { showError(el, 'Invalid album ID'); return; } // Show loading state el.innerHTML = '
Loading carousel...
'; // Fetch album data var album = await fetchAlbumData(albumId); if (!album) { showError(el, 'Album not found (ID: ' + albumId + ')'); return; } var photos = (album.photos || []).slice(0, maxPhotos); if (photos.length === 0) { el.innerHTML = '
This album has no photos yet.
'; return; } // Build carousel el.innerHTML = ''; el.classList.add('carousel-initialized'); // Album title if (showTitle && album.title) { var titleEl = document.createElement('h3'); titleEl.className = 'photo-album-title'; titleEl.textContent = album.title; el.appendChild(titleEl); } // Viewport + track var viewport = document.createElement('div'); viewport.className = 'carousel-viewport'; var track = document.createElement('div'); track.className = 'carousel-track'; for (var i = 0; i < photos.length; i++) { var photo = photos[i]; var slide = document.createElement('div'); slide.className = 'carousel-slide'; var slideLink = document.createElement('a'); slideLink.href = getGalleryUrl(photo.id); slideLink.target = '_blank'; slideLink.title = photo.title || 'Photo ' + photo.id; var slideImg = document.createElement('img'); slideImg.src = MEDIA_API_URL + '/api/public/photos/' + photo.id + '/image?size=large'; slideImg.alt = photo.title || 'Photo ' + photo.id; slideImg.loading = i === 0 ? 'eager' : 'lazy'; slideLink.appendChild(slideImg); slide.appendChild(slideLink); track.appendChild(slide); } viewport.appendChild(track); el.appendChild(viewport); // Navigation buttons (only if > 1 photo) if (photos.length > 1) { var prevBtn = document.createElement('button'); prevBtn.className = 'carousel-btn carousel-btn-prev'; prevBtn.setAttribute('aria-label', 'Previous photo'); prevBtn.innerHTML = '❮'; viewport.appendChild(prevBtn); var nextBtn = document.createElement('button'); nextBtn.className = 'carousel-btn carousel-btn-next'; nextBtn.setAttribute('aria-label', 'Next photo'); nextBtn.innerHTML = '❯'; viewport.appendChild(nextBtn); // Dot indicators var dotsWrap = document.createElement('div'); dotsWrap.className = 'carousel-dots'; for (var d = 0; d < photos.length; d++) { var dot = document.createElement('button'); dot.className = 'carousel-dot' + (d === 0 ? ' active' : ''); dot.setAttribute('aria-label', 'Go to photo ' + (d + 1)); dot.dataset.index = String(d); dotsWrap.appendChild(dot); } el.appendChild(dotsWrap); // Carousel state var currentIndex = 0; var totalSlides = photos.length; var autoPlayTimer = null; function goTo(idx) { if (idx < 0) idx = totalSlides - 1; if (idx >= totalSlides) idx = 0; currentIndex = idx; track.style.transform = 'translateX(-' + (currentIndex * 100) + '%)'; // Update dots var dots = dotsWrap.querySelectorAll('.carousel-dot'); for (var j = 0; j < dots.length; j++) { dots[j].classList.toggle('active', j === currentIndex); } } prevBtn.addEventListener('click', function(e) { e.preventDefault(); goTo(currentIndex - 1); resetAutoPlay(); }); nextBtn.addEventListener('click', function(e) { e.preventDefault(); goTo(currentIndex + 1); resetAutoPlay(); }); dotsWrap.addEventListener('click', function(e) { var target = e.target; if (target.classList.contains('carousel-dot')) { goTo(parseInt(target.dataset.index, 10)); resetAutoPlay(); } }); // Keyboard navigation el.setAttribute('tabindex', '0'); el.addEventListener('keydown', function(e) { if (e.key === 'ArrowLeft') { e.preventDefault(); goTo(currentIndex - 1); resetAutoPlay(); } else if (e.key === 'ArrowRight') { e.preventDefault(); goTo(currentIndex + 1); resetAutoPlay(); } }); // Touch/swipe support var touchStartX = 0; var touchDeltaX = 0; viewport.addEventListener('touchstart', function(e) { touchStartX = e.touches[0].clientX; touchDeltaX = 0; }, { passive: true }); viewport.addEventListener('touchmove', function(e) { touchDeltaX = e.touches[0].clientX - touchStartX; }, { passive: true }); viewport.addEventListener('touchend', function(e) { if (Math.abs(touchDeltaX) > 50) { e.preventDefault(); // suppress follow-up click from swipe if (touchDeltaX < 0) goTo(currentIndex + 1); else goTo(currentIndex - 1); resetAutoPlay(); } touchDeltaX = 0; }); // Auto-play function startAutoPlay() { if (!autoPlayEnabled) return; stopAutoPlay(); autoPlayTimer = setInterval(function() { goTo(currentIndex + 1); }, 5000); } function stopAutoPlay() { if (autoPlayTimer) { clearInterval(autoPlayTimer); autoPlayTimer = null; } } function resetAutoPlay() { stopAutoPlay(); startAutoPlay(); } // Pause auto-play on hover el.addEventListener('mouseenter', stopAutoPlay); el.addEventListener('mouseleave', startAutoPlay); startAutoPlay(); } // "View full album" footer var footer = document.createElement('div'); footer.className = 'photo-album-footer'; var footerLink = document.createElement('a'); footerLink.href = PUBLIC_URL + '/gallery?album=' + albumId; footerLink.target = '_blank'; footerLink.textContent = 'View full album \u2192'; footer.appendChild(footerLink); el.appendChild(footer); console.log('Rendered carousel for album ' + albumId + ' (' + photos.length + ' photos)'); } // ─── Initialization ────────────────────────────────────────────────── /** * Initialize all photo carousel blocks on the page */ function initPhotoCarousels() { var carousels = document.querySelectorAll('.photo-album-carousel'); console.log('Found ' + carousels.length + ' photo carousel(s) to hydrate'); carousels.forEach(function(carousel) { if (carousel.classList.contains('carousel-initialized')) return; // Already rendered renderCarouselBlock(carousel); }); } /** * Initialize all photo blocks on the page */ function initPhotoBlocks() { var blocks = document.querySelectorAll('.photo-block'); console.log('Found ' + blocks.length + ' photo block(s) to hydrate'); blocks.forEach(function(block) { if (block.querySelector('img')) return; // Already rendered renderPhotoBlock(block); }); } /** * Initialize all photo card blocks on the page */ function initPhotoCards() { var cards = document.querySelectorAll('.photo-card-block'); console.log('Found ' + cards.length + ' photo card(s) to hydrate'); cards.forEach(function(card) { if (card.dataset.hydrated === 'true') return; hydratePhotoCard(card); }); } /** * Initialize all photo album blocks on the page */ function initPhotoAlbums() { var albums = document.querySelectorAll('.photo-album-block'); console.log('Found ' + albums.length + ' photo album(s) to hydrate'); albums.forEach(function(album) { if (album.querySelector('.photo-album-grid')) return; // Already rendered renderAlbumBlock(album); }); } /** * Initialize all photo elements (blocks + cards + albums + carousels) */ function initAll() { initPhotoBlocks(); initPhotoCards(); initPhotoAlbums(); initPhotoCarousels(); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initAll); } else { initAll(); } // Re-initialize on MkDocs SPA navigation if (typeof window.document$ !== 'undefined') { window.document$.subscribe(function() { console.log('MkDocs navigation detected, re-initializing photo elements'); setTimeout(initAll, 100); }); } })();