/**
* 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);
});
}
})();