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