bunker-admin 99a6abab06 Add video card insert feature + MkDocs video hydration + fixes
- 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
2026-02-17 15:42:32 -07:00

435 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 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 = '&#9654; 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 = '&#9654; 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);
});
}
})();