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
This commit is contained in:
2026-02-17 15:42:32 -07:00
parent 58dc1942ec
commit 99a6abab06
1511 changed files with 14551 additions and 1625410 deletions

View File

@@ -2,7 +2,7 @@
// Generated by mkdocs/docs/hooks/env_config_hook.py
(function() {
window.MEDIA_API_URL = 'http://localhost:4100';
window.PUBLIC_URL = 'http://localhost:3000';
window.PUBLIC_URL = 'http://localhost:3002';
window.VIDEO_PLAYER_DEBUG = false;
console.log('[Video Config] Loaded from environment:', {

View File

@@ -189,14 +189,202 @@
.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 = '&#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() {
const blocks = document.querySelectorAll('.video-block');
console.log(`Found ${blocks.length} video block(s) to hydrate`);
var blocks = document.querySelectorAll('.video-block');
console.log('Found ' + blocks.length + ' video block(s) to hydrate');
blocks.forEach(block => {
blocks.forEach(function(block) {
// Skip if already rendered (has video element)
if (block.querySelector('video')) {
console.log('Video block already rendered, skipping');
@@ -207,18 +395,40 @@
});
}
/**
* 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', initVideoBlocks);
document.addEventListener('DOMContentLoaded', initAll);
} else {
initVideoBlocks();
initAll();
}
// Re-initialize on MkDocs navigation (for SPA-style navigation)
if (typeof window.document$ !== 'undefined') {
window.document$.subscribe(() => {
console.log('MkDocs navigation detected, re-initializing video blocks');
setTimeout(initVideoBlocks, 100);
window.document$.subscribe(function() {
console.log('MkDocs navigation detected, re-initializing video elements');
setTimeout(initAll, 100);
});
}
})();