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:
@@ -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:', {
|
||||
|
||||
@@ -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 = '▶ 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() {
|
||||
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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user