612 lines
20 KiB
JavaScript
612 lines
20 KiB
JavaScript
/**
|
|
* 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, '"')
|
|
.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 =
|
|
'<div class="photo-error">' +
|
|
'<svg fill="currentColor" viewBox="0 0 20 20">' +
|
|
'<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />' +
|
|
'</svg>' +
|
|
'<p>' + escapeHtml(message) + '</p>' +
|
|
'</div>';
|
|
}
|
|
|
|
/**
|
|
* 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 <img> 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 = '<div class="photo-loading">Loading photo...</div>';
|
|
|
|
// 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 = '<div class="photo-loading">Loading album...</div>';
|
|
|
|
// 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 += '<div class="photo-loading">This album has no photos yet.</div>';
|
|
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 = '<div class="photo-loading">Loading carousel...</div>';
|
|
|
|
// 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 = '<div class="photo-loading">This album has no photos yet.</div>';
|
|
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);
|
|
});
|
|
}
|
|
})();
|