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

@@ -187,18 +187,46 @@ class EmailService {
title: string;
thumbnailUrl: string;
streamUrl: string;
durationSeconds?: number;
quality?: string;
viewCount?: number;
} | null> {
try {
const mediaApiUrl = env.MEDIA_API_PUBLIC_URL || 'http://media-api:4100';
const response = await fetch(`${mediaApiUrl}/api/videos/${videoId}/metadata`);
if (!response.ok) return null;
return await response.json();
return await response.json() as {
id: number;
title: string;
thumbnailUrl: string;
streamUrl: string;
durationSeconds?: number;
quality?: string;
viewCount?: number;
};
} catch (error) {
logger.error(`Failed to fetch video ${videoId} metadata:`, error);
return null;
}
}
private formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return '0:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
private formatViewCount(count: number): string {
if (!count || count <= 0) return '0 views';
if (count === 1) return '1 view';
if (count < 1000) return `${count} views`;
if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K views`;
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M views`;
}
async processTemplate(
template: string,
vars: Record<string, string>,
@@ -213,23 +241,45 @@ class EmailService {
const video = await this.getVideoMetadata(varDef.videoId);
if (video) {
const publicUrl = env.ADMIN_URL || 'http://localhost:3000';
const watchUrl = `${publicUrl}/gallery/watch/${video.id}`;
const duration = this.formatDuration(video.durationSeconds || 0);
const quality = video.quality ? this.escapeHtml(video.quality) : '';
const views = this.formatViewCount(video.viewCount || 0);
const metaLine = [duration, quality, views].filter(Boolean).join(' &bull; ');
const videoHtml = `
<table cellpadding="0" cellspacing="0" border="0" style="max-width: 600px; margin: 0 auto;">
<table cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; margin: 16px auto; border-radius: 8px; overflow: hidden; background-color: #1b2838;">
<tr>
<td>
<a href="${publicUrl}/media/${video.id}" style="display: block; text-decoration: none;">
<td style="padding: 0;">
<a href="${watchUrl}" style="display: block; text-decoration: none;">
<img src="${video.thumbnailUrl}"
alt="${this.escapeHtml(video.title)}"
style="width: 100%; max-width: 600px; height: auto; display: block; border-radius: 8px;" />
width="480"
style="width: 100%; max-width: 480px; height: auto; display: block;" />
</a>
</td>
</tr>
<tr>
<td style="padding-top: 12px; text-align: center;">
<a href="${publicUrl}/media/${video.id}"
style="display: inline-block; padding: 12px 24px; background-color: #0066cc; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">
▶ Watch Video: ${this.escapeHtml(video.title)}
</a>
<td style="padding: 12px 16px;">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="color: #ffffff; font-size: 15px; font-weight: 600; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
${this.escapeHtml(video.title)}
</td>
</tr>
<tr>
<td style="padding-top: 6px; color: #8899aa; font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
${metaLine}
</td>
</tr>
<tr>
<td style="padding-top: 12px; text-align: center;">
<a href="${watchUrl}"
style="display: inline-block; padding: 10px 24px; background-color: #9d4edd; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
&#9654; Watch Video
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
@@ -281,7 +331,11 @@ class EmailService {
const video = await this.getVideoMetadata(varDef.videoId);
if (video) {
const publicUrl = env.ADMIN_URL || 'http://localhost:3000';
const textLink = `Watch Video: ${video.title}\n${publicUrl}/media/${video.id}`;
const duration = this.formatDuration(video.durationSeconds || 0);
const quality = video.quality || '';
const views = this.formatViewCount(video.viewCount || 0);
const meta = [duration, quality, views].filter(Boolean).join(', ');
const textLink = `Watch "${video.title}" (${meta})\n${publicUrl}/gallery/watch/${video.id}`;
result = result.replace(
new RegExp(`\\{\\{${varDef.key}\\}\\}`, 'g'),
textLink
@@ -416,16 +470,16 @@ class EmailService {
if (dbTemplate) {
// Use database template
html = this.processTemplate(dbTemplate.html, vars);
text = this.processTemplate(dbTemplate.text, vars);
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
logger.debug('Using campaign email template from database');
} else {
// Fallback to filesystem
const htmlTemplate = this.loadTemplate('campaign-email', 'html');
const txtTemplate = this.loadTemplate('campaign-email', 'txt');
html = this.processTemplate(htmlTemplate, vars);
text = this.processTemplate(txtTemplate, vars);
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTemplate(txtTemplate, vars);
subject = options.subject; // Use provided subject for filesystem fallback
logger.warn('Using campaign email template from filesystem (fallback)');
}
@@ -725,16 +779,16 @@ class EmailService {
if (dbTemplate) {
// Use database template
html = this.processTemplate(dbTemplate.html, vars);
text = this.processTemplate(dbTemplate.text, vars);
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
logger.debug('Using response verification template from database');
} else {
// Fallback to filesystem
const htmlTemplate = this.loadTemplate('response-verification', 'html');
const txtTemplate = this.loadTemplate('response-verification', 'txt');
html = this.processTemplate(htmlTemplate, vars);
text = this.processTemplate(txtTemplate, vars);
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTemplate(txtTemplate, vars);
subject = `Verify Response — ${options.campaignTitle}`;
logger.warn('Using response verification template from filesystem (fallback)');
}