- New video card block for GrapesJS landing pages, email templates, MkDocs export, and documentation editor Insert dropdown - Shared HTML generators in admin/src/utils/videoCardHtml.ts - MkDocs video-player.js hydrates .video-card-block elements: thumbnail fix via MEDIA_API_URL, click-to-play inline, Gallery link - Media API CORS: auto-add MkDocs + docs subdomain origins - env_config_hook.py: smart Docker hostname detection, ADMIN_PORT resolution, pass env vars to MkDocs container - Gallery URL uses /gallery?expanded=ID format - VideoPickerModal: fix double /api prefix and Docker hostname thumbs - Seed: default-video-card PageBlock - Remove V1 legacy code (influence/, map/) Bunker Admin
435 lines
16 KiB
JavaScript
435 lines
16 KiB
JavaScript
/**
|
||
* Video Player Hydration for MkDocs
|
||
*
|
||
* Automatically converts .video-block placeholders into actual video players
|
||
* when landing pages with video blocks are exported to MkDocs.
|
||
*
|
||
* Supports both standard HTML5 video and advanced player with reactions.
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Configuration
|
||
const MEDIA_API_URL = window.MEDIA_API_URL || 'http://localhost:4100';
|
||
const PUBLIC_URL = window.PUBLIC_URL || 'http://localhost:3000';
|
||
|
||
/**
|
||
* Fetch video metadata from Media API
|
||
*/
|
||
async function fetchVideoMetadata(videoId) {
|
||
try {
|
||
const response = await fetch(`${MEDIA_API_URL}/api/videos/${videoId}/metadata`);
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch video ${videoId}: ${response.statusText}`);
|
||
}
|
||
return await response.json();
|
||
} catch (error) {
|
||
console.error(`Error fetching video ${videoId} metadata:`, error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create standard HTML5 video player
|
||
*/
|
||
function createStandardPlayer(metadata, options) {
|
||
const video = document.createElement('video');
|
||
video.src = `${MEDIA_API_URL}/api/videos/${metadata.id}/stream`;
|
||
video.poster = `${MEDIA_API_URL}/api/videos/${metadata.id}/thumbnail`;
|
||
video.controls = options.controls !== false;
|
||
video.autoplay = options.autoplay === true;
|
||
video.loop = options.loop === true;
|
||
video.muted = options.muted === true;
|
||
video.style.width = options.width || '100%';
|
||
video.style.height = options.height || 'auto';
|
||
video.style.maxWidth = '100%';
|
||
video.style.borderRadius = '8px';
|
||
video.style.display = 'block';
|
||
|
||
// Add title attribute for accessibility
|
||
video.setAttribute('title', metadata.title);
|
||
video.setAttribute('aria-label', metadata.title);
|
||
|
||
return video;
|
||
}
|
||
|
||
/**
|
||
* Create advanced player container with reactions
|
||
* Note: Full reaction functionality requires React components,
|
||
* so this is a simplified version for MkDocs
|
||
*/
|
||
function createAdvancedPlayer(metadata, options) {
|
||
const container = document.createElement('div');
|
||
container.className = 'advanced-video-container';
|
||
container.style.maxWidth = options.width || '100%';
|
||
container.style.margin = '0 auto';
|
||
|
||
// Video player
|
||
const video = createStandardPlayer(metadata, options);
|
||
container.appendChild(video);
|
||
|
||
// Reaction placeholder (simplified for MkDocs)
|
||
if (options.showReactions !== false) {
|
||
const reactionBar = document.createElement('div');
|
||
reactionBar.className = 'video-reactions';
|
||
reactionBar.style.marginTop = '12px';
|
||
reactionBar.style.padding = '12px';
|
||
reactionBar.style.background = '#f5f5f5';
|
||
reactionBar.style.borderRadius = '8px';
|
||
reactionBar.style.textAlign = 'center';
|
||
reactionBar.style.fontSize = '14px';
|
||
reactionBar.style.color = '#666';
|
||
reactionBar.innerHTML = `
|
||
<p style="margin: 0;">
|
||
<a href="${PUBLIC_URL}/media/${metadata.id}" target="_blank" style="color: #1890ff; text-decoration: none;">
|
||
View full player with reactions →
|
||
</a>
|
||
</p>
|
||
`;
|
||
container.appendChild(reactionBar);
|
||
}
|
||
|
||
// Video metadata
|
||
const metaInfo = document.createElement('div');
|
||
metaInfo.className = 'video-meta';
|
||
metaInfo.style.marginTop = '8px';
|
||
metaInfo.style.fontSize = '13px';
|
||
metaInfo.style.color = '#999';
|
||
metaInfo.innerHTML = `
|
||
<strong style="color: #333;">${escapeHtml(metadata.title)}</strong>
|
||
${metadata.durationSeconds ? `<span style="margin-left: 8px;">• ${formatDuration(metadata.durationSeconds)}</span>` : ''}
|
||
${metadata.width && metadata.height ? `<span style="margin-left: 8px;">• ${metadata.width}×${metadata.height}</span>` : ''}
|
||
`;
|
||
container.appendChild(metaInfo);
|
||
|
||
return container;
|
||
}
|
||
|
||
/**
|
||
* Render a video block
|
||
*/
|
||
async function renderVideoBlock(blockElement) {
|
||
const videoId = parseInt(blockElement.dataset.videoId, 10);
|
||
const playerType = blockElement.dataset.playerType || 'standard';
|
||
const width = blockElement.dataset.width || '100%';
|
||
const height = blockElement.dataset.height || 'auto';
|
||
const autoplay = blockElement.dataset.autoplay === 'true';
|
||
const controls = blockElement.dataset.controls !== 'false';
|
||
const showReactions = blockElement.dataset.showReactions !== 'false';
|
||
|
||
if (!videoId || isNaN(videoId)) {
|
||
console.warn('Invalid video ID in video block:', blockElement);
|
||
showError(blockElement, 'Invalid video ID');
|
||
return;
|
||
}
|
||
|
||
// Show loading state
|
||
blockElement.innerHTML = '<div class="video-loading" style="text-align: center; padding: 40px; color: #999;">Loading video...</div>';
|
||
|
||
// Fetch metadata
|
||
const metadata = await fetchVideoMetadata(videoId);
|
||
if (!metadata) {
|
||
showError(blockElement, `Video not found (ID: ${videoId})`);
|
||
return;
|
||
}
|
||
|
||
// Create player
|
||
const options = { width, height, autoplay, controls, showReactions };
|
||
const player = playerType === 'advanced'
|
||
? createAdvancedPlayer(metadata, options)
|
||
: createStandardPlayer(metadata, options);
|
||
|
||
// Replace placeholder with player
|
||
blockElement.innerHTML = '';
|
||
blockElement.appendChild(player);
|
||
|
||
console.log(`Rendered ${playerType} video player for video ${videoId}`);
|
||
}
|
||
|
||
/**
|
||
* Show error state
|
||
*/
|
||
function showError(blockElement, message) {
|
||
blockElement.innerHTML = `
|
||
<div class="video-error" style="
|
||
padding: 40px;
|
||
text-align: center;
|
||
background: #fff3f3;
|
||
border: 1px solid #ffccc7;
|
||
border-radius: 8px;
|
||
color: #cf1322;
|
||
">
|
||
<svg style="width: 48px; height: 48px; margin-bottom: 12px;" 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 style="margin: 0; font-weight: 600;">${escapeHtml(message)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Format duration in seconds to MM:SS
|
||
*/
|
||
function formatDuration(seconds) {
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
/**
|
||
* 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 the gallery URL for a video.
|
||
*/
|
||
function getGalleryUrl(videoId) {
|
||
return PUBLIC_URL + '/gallery?expanded=' + videoId;
|
||
}
|
||
|
||
/**
|
||
* Hydrate a video card block:
|
||
* 1. Fix thumbnail URL using MEDIA_API_URL
|
||
* 2. Fetch live metadata (views, title)
|
||
* 3. Clicking thumbnail plays video inline
|
||
* 4. Replace "Watch →" with Gallery link
|
||
*/
|
||
async function hydrateVideoCard(cardElement) {
|
||
var videoId = parseInt(cardElement.dataset.videoId, 10);
|
||
if (!videoId || isNaN(videoId)) return;
|
||
|
||
var title = cardElement.dataset.videoTitle || 'Video';
|
||
|
||
// 1. Fix thumbnail URL — replace placeholder/broken src with MEDIA_API_URL
|
||
var img = cardElement.querySelector('img');
|
||
if (img) {
|
||
// Cache-buster avoids stale CORS-error cached responses
|
||
var correctThumbUrl = MEDIA_API_URL + '/api/videos/' + videoId + '/thumbnail?v=' + Date.now();
|
||
// Always replace src — data URI placeholder, Docker hostname, or stale URL
|
||
img.src = correctThumbUrl;
|
||
// Fallback if thumbnail fails to load — show a styled placeholder instead of broken image
|
||
img.onerror = function() {
|
||
img.style.display = 'none';
|
||
};
|
||
}
|
||
|
||
// 2. Fetch live metadata to update views/title
|
||
try {
|
||
var metadata = await fetchVideoMetadata(videoId);
|
||
if (metadata) {
|
||
var viewsEl = cardElement.querySelector('[data-role="views"]');
|
||
if (viewsEl && metadata.viewCount !== undefined) {
|
||
viewsEl.textContent = formatViewCount(metadata.viewCount);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Non-critical — card works fine without live metadata
|
||
}
|
||
|
||
// 3. Prevent the <a> tag from navigating
|
||
var link = cardElement.querySelector('a');
|
||
if (link) {
|
||
link.addEventListener('click', function(e) { e.preventDefault(); });
|
||
}
|
||
|
||
// 4. Fix the play button overlay SVG + set up click-to-play
|
||
var thumbContainer = cardElement.querySelector('a > div:first-child');
|
||
if (thumbContainer) {
|
||
// Replace old tiny play icon with proper triangle
|
||
var playOverlay = thumbContainer.querySelector('div:last-child');
|
||
if (playOverlay && playOverlay.querySelector('svg')) {
|
||
playOverlay.innerHTML = '<svg width="28" height="28" viewBox="0 0 24 24" fill="#fff" style="margin-left:3px;"><polygon points="5,3 19,12 5,21"/></svg>';
|
||
}
|
||
|
||
thumbContainer.style.cursor = 'pointer';
|
||
thumbContainer.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
// Don't re-create if already playing
|
||
if (cardElement.querySelector('video')) return;
|
||
|
||
var streamUrl = MEDIA_API_URL + '/api/videos/' + videoId + '/stream';
|
||
var thumbUrl = MEDIA_API_URL + '/api/videos/' + videoId + '/thumbnail';
|
||
|
||
// Build inline player — locked to same 16:9 aspect ratio as the card
|
||
var playerContainer = document.createElement('div');
|
||
playerContainer.className = 'video-card-player';
|
||
playerContainer.style.cssText = 'max-width:480px;margin:0 auto;border-radius:12px;overflow:hidden;background:#0d1b2a;box-shadow:0 4px 12px rgba(0,0,0,0.3);';
|
||
|
||
// 16:9 aspect ratio wrapper prevents container resize when video loads
|
||
var videoWrapper = document.createElement('div');
|
||
videoWrapper.style.cssText = 'position:relative;padding-bottom:56.25%;background:#000;';
|
||
|
||
var video = document.createElement('video');
|
||
video.src = streamUrl;
|
||
video.poster = thumbUrl;
|
||
video.controls = true;
|
||
video.autoplay = true;
|
||
video.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;display:block;border-radius:12px 12px 0 0;';
|
||
video.setAttribute('title', title);
|
||
|
||
videoWrapper.appendChild(video);
|
||
|
||
var infoBar = document.createElement('div');
|
||
infoBar.style.cssText = 'padding:10px 16px;background:#1b2838;display:flex;align-items:center;gap:8px;';
|
||
|
||
var titleSpan = document.createElement('span');
|
||
titleSpan.style.cssText = 'color:#fff;font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;';
|
||
titleSpan.textContent = title;
|
||
|
||
var galleryLink = document.createElement('a');
|
||
galleryLink.href = getGalleryUrl(videoId);
|
||
galleryLink.target = '_blank';
|
||
galleryLink.innerHTML = '▶ Gallery';
|
||
galleryLink.className = 'video-card-gallery-btn';
|
||
galleryLink.style.cssText = 'background:#9d4edd;border:none;color:#fff;font-size:12px;font-weight:600;padding:5px 12px;border-radius:4px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:background 0.15s;text-decoration:none;display:inline-block;';
|
||
galleryLink.title = 'Open in gallery';
|
||
galleryLink.onmouseenter = function() { galleryLink.style.background = '#b06ce6'; };
|
||
galleryLink.onmouseleave = function() { galleryLink.style.background = '#9d4edd'; };
|
||
galleryLink.addEventListener('click', function(ev) {
|
||
ev.stopPropagation();
|
||
});
|
||
|
||
var closeBtn = document.createElement('button');
|
||
closeBtn.textContent = '\u2715';
|
||
closeBtn.style.cssText = 'background:none;border:none;color:#8899aa;font-size:16px;cursor:pointer;padding:4px;flex-shrink:0;transition:color 0.15s;';
|
||
closeBtn.title = 'Close player';
|
||
closeBtn.onmouseenter = function() { closeBtn.style.color = '#fff'; };
|
||
closeBtn.onmouseleave = function() { closeBtn.style.color = '#8899aa'; };
|
||
|
||
infoBar.appendChild(titleSpan);
|
||
infoBar.appendChild(galleryLink);
|
||
infoBar.appendChild(closeBtn);
|
||
playerContainer.appendChild(videoWrapper);
|
||
playerContainer.appendChild(infoBar);
|
||
|
||
// Save original card content and replace
|
||
var originalContent = cardElement.innerHTML;
|
||
cardElement.innerHTML = '';
|
||
cardElement.appendChild(playerContainer);
|
||
|
||
// Close button restores original card
|
||
closeBtn.addEventListener('click', function(ev) {
|
||
ev.stopPropagation();
|
||
video.pause();
|
||
video.src = '';
|
||
cardElement.innerHTML = originalContent;
|
||
hydrateVideoCard(cardElement);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 5. Replace "Watch →" text with a Gallery button in the card info bar
|
||
var infoBar = cardElement.querySelector('a > div:last-child');
|
||
if (infoBar) {
|
||
var flexRow = infoBar.querySelector('div:last-child');
|
||
if (flexRow) {
|
||
var spans = flexRow.querySelectorAll('span');
|
||
var watchSpan = null;
|
||
for (var i = 0; i < spans.length; i++) {
|
||
if (spans[i].textContent.indexOf('Watch') !== -1) {
|
||
watchSpan = spans[i];
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (watchSpan) {
|
||
var galleryLink = document.createElement('a');
|
||
galleryLink.href = getGalleryUrl(videoId);
|
||
galleryLink.target = '_blank';
|
||
galleryLink.innerHTML = '▶ Gallery';
|
||
galleryLink.className = 'video-card-gallery-btn';
|
||
galleryLink.style.cssText = 'background:#9d4edd;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 = '#b06ce6'; };
|
||
galleryLink.onmouseleave = function() { galleryLink.style.background = '#9d4edd'; };
|
||
galleryLink.addEventListener('click', function(ev) {
|
||
ev.stopPropagation(); // Don't trigger card click-to-play
|
||
});
|
||
watchSpan.replaceWith(galleryLink);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Mark as hydrated
|
||
cardElement.dataset.hydrated = 'true';
|
||
console.log('Hydrated video card for video ' + videoId);
|
||
}
|
||
|
||
/**
|
||
* Initialize all video blocks on page load
|
||
*/
|
||
function initVideoBlocks() {
|
||
var blocks = document.querySelectorAll('.video-block');
|
||
console.log('Found ' + blocks.length + ' video block(s) to hydrate');
|
||
|
||
blocks.forEach(function(block) {
|
||
// Skip if already rendered (has video element)
|
||
if (block.querySelector('video')) {
|
||
console.log('Video block already rendered, skipping');
|
||
return;
|
||
}
|
||
|
||
renderVideoBlock(block);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Initialize all video card blocks on page load
|
||
*/
|
||
function initVideoCards() {
|
||
var cards = document.querySelectorAll('.video-card-block');
|
||
console.log('Found ' + cards.length + ' video card(s) to hydrate');
|
||
|
||
cards.forEach(function(card) {
|
||
// Skip if already hydrated
|
||
if (card.dataset.hydrated === 'true') return;
|
||
hydrateVideoCard(card);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Initialize all video elements (blocks + cards)
|
||
*/
|
||
function initAll() {
|
||
initVideoBlocks();
|
||
initVideoCards();
|
||
}
|
||
|
||
// Initialize when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', initAll);
|
||
} else {
|
||
initAll();
|
||
}
|
||
|
||
// Re-initialize on MkDocs navigation (for SPA-style navigation)
|
||
if (typeof window.document$ !== 'undefined') {
|
||
window.document$.subscribe(function() {
|
||
console.log('MkDocs navigation detected, re-initializing video elements');
|
||
setTimeout(initAll, 100);
|
||
});
|
||
}
|
||
})();
|