Delete confirm inline bar, lighter login, Presentation type with Marp+PPTX

Delete confirmation:
- Replaced browser confirm() popup with inline red warning bar in editor
- Shows content title, Cancel and Delete buttons inline in the editor header
- Category delete uses double-click pattern with toast feedback

Login screen:
- Changed from dark blue/purple gradient to light gray background (var(--g50))
- Card now matches 404 page style: white, subtle blue shadow, indigo border

Presentation content type:
- New 'Presentation' button in CMS toolbar (green style)
- Marp markdown textarea editor (dark code editor style) shown when type=presentation
- Body/Tiptap editor hidden; Marp section shown (toggleEditorMode)
- Preview button: renders Marp HTML via @marp-team/marp-core, opens in new tab
- Download PPTX: pure-JS pptxgenjs (no Chromium), parses slides, exports .pptx
- AI generate: returns Marp markdown directly when contentType=presentation
- Body column stores Marp markdown for presentations

Backend:
- POST /api/admin/learning/generate-pptx — pptxgenjs PPTX from Marp markdown
- POST /api/admin/learning/preview-slides — Marp HTML preview

GitHub repo set to private via API.
This commit is contained in:
Daniel Onyejesi 2026-03-24 00:59:36 -04:00
parent 936ad7e8a0
commit 020cc76f2f
6 changed files with 2152 additions and 25 deletions

1800
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,8 @@
"start": "node server.js"
},
"dependencies": {
"@marp-team/marp-cli": "^4.3.1",
"@marp-team/marp-core": "^4.3.0",
"@tiptap/core": "^3.20.4",
"@tiptap/extension-color": "^3.20.4",
"@tiptap/extension-link": "^3.20.4",
@ -28,6 +30,7 @@
"openai": "^4.73.0",
"pdf-parse": "^2.4.5",
"pg": "^8.13.0",
"pptxgenjs": "^4.0.1",
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0"
},

View file

@ -49,6 +49,7 @@
<button id="btn-lh-new-content" class="btn-sm btn-primary" data-type="article"><i class="fas fa-file-alt"></i> New Article</button>
<button id="btn-lh-new-quiz" class="btn-sm" style="background:var(--amber-light);color:#92400e;border:1px solid var(--amber);border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;"><i class="fas fa-clipboard-question"></i> New Quiz</button>
<button id="btn-lh-new-pearl" class="btn-sm" style="background:var(--purple-light);color:var(--purple);border:1px solid var(--purple);border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;"><i class="fas fa-gem"></i> New Pearl</button>
<button id="btn-lh-new-presentation" class="btn-sm" style="background:#ecfdf5;color:#065f46;border:1px solid #6ee7b7;border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;"><i class="fas fa-presentation-screen"></i> New Presentation</button>
</div>
<div class="cms-search">
<i class="fas fa-search"></i>
@ -85,6 +86,15 @@
<button id="btn-lh-save-content" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save</button>
</div>
</div>
<!-- Inline delete confirmation bar (replaces the browser confirm popup) -->
<div id="lh-delete-confirm-bar" class="lh-delete-confirm-bar hidden">
<i class="fas fa-triangle-exclamation"></i>
<span>Delete <strong id="lh-delete-confirm-title"></strong>? All questions will be removed. This cannot be undone.</span>
<div style="display:flex;gap:8px;margin-left:auto;flex-shrink:0;">
<button id="btn-lh-delete-cancel" class="btn-sm btn-ghost">Cancel</button>
<button id="btn-lh-delete-yes" class="btn-sm" style="background:var(--red);color:white;">Delete</button>
</div>
</div>
<input type="hidden" id="lh-cms-edit-id" value="">
@ -109,6 +119,7 @@
<option value="article">Article</option>
<option value="quiz">Quiz Only</option>
<option value="pearl">Clinical Pearl</option>
<option value="presentation">Presentation</option>
</select>
</div>
</div>
@ -179,6 +190,7 @@
<option value="article">Article</option>
<option value="quiz">Quiz</option>
<option value="pearl">Clinical Pearl</option>
<option value="presentation">Presentation</option>
</select>
</div>
<div class="lh-ai-opt-field" style="flex:2;">
@ -202,12 +214,43 @@
</div>
</div>
<!-- Body editor (Tiptap rich text) -->
<div class="cms-body-section">
<!-- Body editor (Tiptap rich text) — hidden for presentations -->
<div id="lh-body-section" class="cms-body-section">
<label class="cms-label">Body</label>
<div id="lh-body-editor" class="cms-quill-body"></div>
</div>
<!-- Marp markdown editor — shown only for presentations -->
<div id="lh-marp-section" class="cms-body-section hidden">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<label class="cms-label" style="margin:0;">Slides <span style="font-size:11px;color:var(--g400);font-weight:400;">(Marp markdown — separate slides with <code>---</code>)</span></label>
<div style="display:flex;gap:6px;">
<button id="btn-lh-preview-slides" class="btn-sm btn-ghost" style="font-size:12px;"><i class="fas fa-eye"></i> Preview</button>
<button id="btn-lh-download-pptx" class="btn-sm" style="background:#065f46;color:white;font-size:12px;border:none;border-radius:6px;padding:5px 12px;cursor:pointer;"><i class="fas fa-file-powerpoint"></i> Download PPTX</button>
</div>
</div>
<textarea id="lh-marp-editor" class="lh-marp-textarea" placeholder="---
marp: true
theme: default
---
# Slide Title
Your content here
---
# Second Slide
- Bullet point one
- Bullet point two
- Bullet point three
---
# Thank You"></textarea>
</div>
<!-- Quiz builder -->
<div class="cms-quiz-section">
<div class="cms-quiz-header">

View file

@ -13,8 +13,8 @@
body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--g800);line-height:1.6;}
/* AUTH */
.auth-screen{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,var(--blue) 0%,var(--purple) 100%);padding:20px;}
.auth-box{background:white;border-radius:16px;padding:40px;max-width:440px;width:100%;box-shadow:0 20px 40px rgba(0,0,0,0.2);}
.auth-screen{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;background:var(--g50);padding:20px;}
.auth-box{background:white;border-radius:20px;padding:40px;max-width:440px;width:100%;box-shadow:0 4px 6px -1px rgba(0,0,0,.07),0 20px 60px -10px rgba(37,99,235,.12);border:1px solid #e0e7ff;}
.auth-logo{text-align:center;margin-bottom:30px;}
.auth-logo i{font-size:40px;color:var(--blue);margin-bottom:10px;}
.auth-logo h1{font-size:24px;color:var(--g900);}
@ -710,6 +710,14 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.lh-quiz-option input[type="checkbox"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
.lh-quiz-multi-hint{font-size:12px;color:var(--g500);margin-bottom:10px;font-style:italic;}
/* ── Inline delete confirmation bar ──────────────────────── */
.lh-delete-confirm-bar{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#fef2f2;border:1.5px solid #fca5a5;border-radius:10px;margin-bottom:12px;font-size:13px;color:#991b1b;flex-wrap:wrap;}
.lh-delete-confirm-bar i{color:var(--red);flex-shrink:0;}
/* ── Marp / Presentation editor ──────────────────────────── */
.lh-marp-textarea{width:100%;min-height:320px;font-family:'Courier New',monospace;font-size:13px;line-height:1.6;padding:14px;border:1px solid var(--g300);border-radius:8px;resize:vertical;box-sizing:border-box;background:var(--g900);color:#e5e7eb;outline:none;}
.lh-marp-textarea:focus{border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-light);}
/* ── AI Generate panel ────────────────────────────────────── */
.lh-ai-panel{border:2px solid transparent;background:linear-gradient(white,white) padding-box,linear-gradient(135deg,#7c3aed,#2563eb) border-box;border-radius:12px;padding:20px;margin-bottom:16px;}
.lh-ai-panel-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;}

View file

@ -17,7 +17,7 @@
}
// Load CMS when Content Manager tab is opened
if (e.detail && e.detail.tab === 'cms') {
if (!cmsLoaded) { loadCms(); cmsLoaded = true; wireCmsFileInput(); }
if (!cmsLoaded) { loadCms(); cmsLoaded = true; wireCmsFileInput(); wireCmsTypeSelect(); }
}
});
@ -60,11 +60,18 @@
if (e.target.closest('#btn-lh-new-content')) { openEditor(null, 'article'); return; }
if (e.target.closest('#btn-lh-new-quiz')) { openEditor(null, 'quiz'); return; }
if (e.target.closest('#btn-lh-new-pearl')) { openEditor(null, 'pearl'); return; }
if (e.target.closest('#btn-lh-new-presentation')) { openEditor(null, 'presentation'); return; }
if (e.target.closest('#btn-lh-close-editor')) { closeEditor(); return; }
if (e.target.closest('#btn-lh-save-content')) { saveContent(); return; }
if (e.target.closest('#btn-lh-delete-content')) { deleteContent(); return; }
if (e.target.closest('#btn-lh-delete-content')) { showDeleteConfirm(); return; }
if (e.target.closest('#btn-lh-delete-yes')) { confirmDelete(); return; }
if (e.target.closest('#btn-lh-delete-cancel')) { hideDeleteConfirm(); return; }
if (e.target.closest('#btn-lh-add-question')) { addQuestionBlock(); return; }
// Presentation
if (e.target.closest('#btn-lh-preview-slides')) { previewSlides(); return; }
if (e.target.closest('#btn-lh-download-pptx')) { downloadPptx(); return; }
// AI panel
if (e.target.closest('#btn-lh-ai-open')) { openAiPanel(); return; }
if (e.target.closest('#btn-lh-ai-close')) { closeAiPanel(); return; }
@ -444,6 +451,13 @@
// ADMIN CMS
// ============================================================
function wireCmsTypeSelect() {
var typeEl = document.getElementById('lh-cms-edit-type');
if (typeEl) {
typeEl.addEventListener('change', function() { toggleEditorMode(typeEl.value); });
}
}
function wireCmsFileInput() {
var fileInput = document.getElementById('lh-ai-file');
var fileLabel = document.getElementById('lh-ai-file-label');
@ -654,7 +668,9 @@
hideLoading();
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate Content'; }
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
applyAiContent(data.content, contentType);
// Presentation returns marpMarkdown directly; others return content{}
var payload = data.contentType === 'presentation' ? data : data.content;
applyAiContent(payload, contentType);
closeAiPanel();
showToast('Content generated! Review and save.', 'success');
})
@ -666,18 +682,29 @@
}
function applyAiContent(content, contentType) {
// Set content type first so toggleEditorMode works correctly
var typeEl = document.getElementById('lh-cms-edit-type');
if (typeEl && contentType) { typeEl.value = contentType; toggleEditorMode(contentType); }
// Presentation: fill Marp textarea
if (contentType === 'presentation' && content.marpMarkdown) {
var marpTa = document.getElementById('lh-marp-editor');
if (marpTa) marpTa.value = content.marpMarkdown;
// Extract title from first # heading
var titleMatch = content.marpMarkdown.match(/^#\s+(.+)$/m);
var titleEl = document.getElementById('lh-cms-edit-title');
if (titleEl && titleMatch) titleEl.value = titleMatch[1];
return;
}
// Fill title
var titleEl = document.getElementById('lh-cms-edit-title');
if (titleEl && content.title) titleEl.value = content.title;
var titleEl2 = document.getElementById('lh-cms-edit-title');
if (titleEl2 && content.title) titleEl2.value = content.title;
// Fill subject
var subjectEl = document.getElementById('lh-cms-edit-subject');
if (subjectEl && content.subject) subjectEl.value = content.subject;
// Set content type
var typeEl = document.getElementById('lh-cms-edit-type');
if (typeEl && contentType) typeEl.value = contentType;
// Fill body via Tiptap
if (_bodyEditor && content.body) _bodyEditor.commands.setContent(content.body);
@ -792,7 +819,13 @@
}
function deleteCategory(id) {
if (!confirm('Delete this category? Content will become uncategorized.')) return;
if (!window.__confirmDeleteCat) {
window.__confirmDeleteCat = id;
showToast('Click delete again to confirm removing this category', 'info');
setTimeout(function() { window.__confirmDeleteCat = null; }, 4000);
return;
}
window.__confirmDeleteCat = null;
fetch('/api/admin/learning/categories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
@ -1107,8 +1140,12 @@
document.getElementById('lh-cms-edit-published').value = 'false';
document.getElementById('lh-cms-questions').innerHTML = '';
document.getElementById('btn-lh-delete-content').classList.add('hidden');
hideDeleteConfirm();
toggleEditorMode(contentType || 'article');
initBodyEditor();
if (_bodyEditor) _bodyEditor.commands.clearContent(true);
var marpTa = document.getElementById('lh-marp-editor');
if (marpTa) marpTa.value = '';
// For quiz type, add a starter question
if (contentType === 'quiz' && !contentId) {
@ -1126,11 +1163,18 @@
document.getElementById('lh-cms-edit-id').value = c.id;
document.getElementById('lh-cms-edit-title').value = c.title;
document.getElementById('lh-cms-edit-subject').value = c.subject || '';
if (_bodyEditor) _bodyEditor.commands.setContent(c.body || '');
document.getElementById('lh-cms-edit-category').value = c.category_id || '';
document.getElementById('lh-cms-edit-type').value = c.content_type || 'article';
document.getElementById('lh-cms-edit-published').value = c.published ? 'true' : 'false';
document.getElementById('btn-lh-delete-content').classList.remove('hidden');
toggleEditorMode(c.content_type || 'article');
if (c.content_type === 'presentation') {
var marpEl = document.getElementById('lh-marp-editor');
if (marpEl) marpEl.value = c.body || '';
} else {
if (_bodyEditor) _bodyEditor.commands.setContent(c.body || '');
}
// Load questions
var qContainer = document.getElementById('lh-cms-questions');
@ -1143,20 +1187,31 @@
});
}
function toggleEditorMode(contentType) {
var bodySection = document.getElementById('lh-body-section');
var marpSection = document.getElementById('lh-marp-section');
var isPresentation = contentType === 'presentation';
if (bodySection) bodySection.classList.toggle('hidden', isPresentation);
if (marpSection) marpSection.classList.toggle('hidden', !isPresentation);
}
function closeEditor() {
var editor = document.getElementById('lh-cms-editor');
var listView = document.getElementById('cms-list-view');
if (editor) editor.classList.add('hidden');
if (listView) listView.classList.remove('hidden');
hideDeleteConfirm();
}
function saveContent() {
var id = document.getElementById('lh-cms-edit-id').value;
var title = document.getElementById('lh-cms-edit-title').value.trim();
var subject = document.getElementById('lh-cms-edit-subject').value.trim();
var body = _bodyEditor ? getTpHTML(_bodyEditor) : '';
var category_id = document.getElementById('lh-cms-edit-category').value || null;
var content_type = document.getElementById('lh-cms-edit-type').value;
var body = content_type === 'presentation'
? (document.getElementById('lh-marp-editor') ? document.getElementById('lh-marp-editor').value : '')
: (_bodyEditor ? getTpHTML(_bodyEditor) : '');
var category_id = document.getElementById('lh-cms-edit-category').value || null;
var published = document.getElementById('lh-cms-edit-published').value === 'true';
if (!title) { showToast('Title required', 'error'); return; }
@ -1265,11 +1320,27 @@
});
}
function deleteContent() {
function showDeleteConfirm() {
var id = document.getElementById('lh-cms-edit-id').value;
if (!id) return;
if (!confirm('Delete this content and all its questions? This cannot be undone.')) return;
var title = (document.getElementById('lh-cms-edit-title').value || 'this content').substring(0, 50);
var bar = document.getElementById('lh-delete-confirm-bar');
var titleEl = document.getElementById('lh-delete-confirm-title');
if (!bar) return;
if (titleEl) titleEl.textContent = '"' + title + '"';
bar.classList.remove('hidden');
bar.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function hideDeleteConfirm() {
var bar = document.getElementById('lh-delete-confirm-bar');
if (bar) bar.classList.add('hidden');
}
function confirmDelete() {
var id = document.getElementById('lh-cms-edit-id').value;
if (!id) return;
hideDeleteConfirm();
fetch('/api/admin/learning/content/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
@ -1283,6 +1354,60 @@
});
}
// ── Presentation / Marp ──────────────────────────────────────
function getMarpMarkdown() {
var ta = document.getElementById('lh-marp-editor');
return ta ? ta.value.trim() : '';
}
function previewSlides() {
var md = getMarpMarkdown();
if (!md) { showToast('No slide content yet', 'error'); return; }
fetch('/api/admin/learning/preview-slides', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ markdown: md })
})
.then(function(r) { return r.text(); })
.then(function(html) {
var win = window.open('', '_blank');
if (win) { win.document.write(html); win.document.close(); }
else showToast('Allow popups to preview slides', 'info');
})
.catch(function(err) { showToast(err.message, 'error'); });
}
function downloadPptx() {
var md = getMarpMarkdown();
if (!md) { showToast('No slide content yet', 'error'); return; }
var title = document.getElementById('lh-cms-edit-title').value || 'presentation';
var btn = document.getElementById('btn-lh-download-pptx');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Building...'; }
fetch('/api/admin/learning/generate-pptx', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ markdown: md, title: title })
})
.then(function(r) {
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error); });
return r.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = title.replace(/\s+/g, '-').toLowerCase() + '.pptx';
a.click();
URL.revokeObjectURL(url);
showToast('PPTX downloaded!', 'success');
})
.catch(function(err) { showToast(err.message, 'error'); })
.finally(function() {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-file-powerpoint"></i> Download PPTX'; }
});
}
function addQuestionBlock(existingQ) {
var container = document.getElementById('lh-cms-questions');
if (!container) return;

View file

@ -58,6 +58,28 @@ function buildGeneratePrompt(opts) {
? 'Based on the following document/resource text, generate educational content.\n\nDOCUMENT:\n"""\n' + docText.substring(0, 12000) + '\n"""\n'
: 'Generate educational content on the following topic for a medical professional audience (pediatrics / primary care).\n\nTOPIC: ' + topic + '\n';
var refineInstr = refinement ? '\n\nAdditional instructions for tone/style/focus: ' + refinement : '';
// ── Presentation: return Marp markdown, not JSON ──
if (contentType === 'presentation') {
return source + '\n' +
'Create a professional Marp presentation (8-12 slides) suitable for medical education. ' +
'Each slide should be focused and readable.' + refineInstr + `
Return ONLY valid Marp markdown (no JSON, no code fences). Start with frontmatter:
---
marp: true
theme: default
---
Then each slide separated by ---. Guidelines:
- First slide: title slide with presentation name and brief subtitle
- Use # for slide titles
- Use bullet points (- ) for lists, keep them concise (max 5 bullets per slide)
- Include a summary/key takeaways slide at the end
- Do NOT include HTML tags or inline styles`;
}
var typeInstr = '';
if (contentType === 'quiz') {
typeInstr = 'This is a quiz-only resource. Write a brief introductory body (1-2 paragraphs), then generate ' + questionCount + ' quiz questions.';
@ -67,8 +89,6 @@ function buildGeneratePrompt(opts) {
typeInstr = 'This is an article. Write a comprehensive body (4-8 paragraphs), then generate ' + questionCount + ' quiz questions.';
}
var refineInstr = refinement ? '\n\nAdditional instructions for tone/style/focus: ' + refinement : '';
return source + '\n' + typeInstr + refineInstr + `
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
@ -146,8 +166,20 @@ router.post('/ai-generate', upload.single('file'), async function(req, res) {
{ model: model, temperature: 0.4, maxTokens: 6000 }
);
// Parse JSON from AI response
var raw = result.content.trim();
// ── Presentation: return Marp markdown directly ──
if (contentType === 'presentation') {
var marpMd = raw.replace(/^```(?:markdown|marp)?\s*/i, '').replace(/\s*```\s*$/, '');
return res.json({
success: true,
contentType: 'presentation',
marpMarkdown: marpMd,
model: result.model,
docLength: docText.length
});
}
// Strip code fences if model added them anyway
raw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
@ -155,7 +187,6 @@ router.post('/ai-generate', upload.single('file'), async function(req, res) {
try {
parsed = JSON.parse(raw);
} catch (e) {
// Try to extract JSON object from surrounding text
var match = raw.match(/\{[\s\S]*\}/);
if (match) {
try { parsed = JSON.parse(match[0]); }
@ -301,4 +332,125 @@ router.post('/webdav-path', async function(req, res) {
}
});
// ── POST /api/admin/learning/generate-pptx ──────────────────
// Convert Marp markdown to PPTX using pptxgenjs (no browser needed)
router.post('/generate-pptx', async function(req, res) {
try {
var { markdown, title } = req.body;
if (!markdown) return res.status(400).json({ error: 'markdown required' });
var PptxGenJS = require('pptxgenjs');
var pptx = new PptxGenJS();
pptx.layout = 'LAYOUT_WIDE'; // 16:9
// Strip frontmatter
var md = markdown.replace(/^---[\s\S]*?---\n?/, '').trim();
// Split into slides on ---
var rawSlides = md.split(/\n---\n/);
rawSlides.forEach(function(slideText) {
slideText = slideText.trim();
if (!slideText) return;
var slide = pptx.addSlide();
// Add subtle background
slide.background = { color: 'FFFFFF' };
// Extract title (first # heading)
var titleMatch = slideText.match(/^#{1,3}\s+(.+)$/m);
var slideTitle = titleMatch ? titleMatch[1].trim() : '';
var body = slideText.replace(/^#{1,3}\s+.+$/m, '').trim();
// Title
if (slideTitle) {
slide.addText(slideTitle, {
x: 0.5, y: 0.4, w: '90%', h: 0.8,
fontSize: 28, bold: true, color: '1e40af',
fontFace: 'Calibri'
});
}
// Parse body: bullet lines (- item) and paragraphs
if (body) {
var lines = body.split('\n').filter(function(l) { return l.trim(); });
var bullets = [];
var paragraphs = [];
lines.forEach(function(line) {
var bulletMatch = line.match(/^[-*]\s+(.+)/);
if (bulletMatch) {
bullets.push(bulletMatch[1].trim());
} else if (!line.startsWith('#')) {
// Strip inline markdown
var clean = line.replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/`(.+?)`/g, '$1');
paragraphs.push(clean);
}
});
var contentY = slideTitle ? 1.4 : 0.8;
if (bullets.length > 0) {
var bulletObjs = bullets.map(function(b) {
return { text: b, options: { bullet: { type: 'bullet' }, fontSize: 18, color: '374151', paraSpaceBefore: 6 } };
});
slide.addText(bulletObjs, {
x: 0.6, y: contentY, w: '88%', h: (5 - contentY),
fontFace: 'Calibri', valign: 'top'
});
} else if (paragraphs.length > 0) {
slide.addText(paragraphs.join('\n'), {
x: 0.5, y: contentY, w: '90%', h: (5 - contentY),
fontSize: 18, color: '374151', fontFace: 'Calibri',
valign: 'top', wrap: true
});
}
}
// Slide number bottom right
slide.addText('', { x: '85%', y: '90%', w: '12%', h: 0.3, fontSize: 10, color: 'aaaaaa', align: 'right' });
});
// Write to buffer and send
var pptxBuffer = await pptx.write({ outputType: 'nodebuffer' });
var safeTitle = (title || 'presentation').replace(/[^a-zA-Z0-9-_\s]/g, '').replace(/\s+/g, '-').toLowerCase();
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.presentationml.presentation');
res.setHeader('Content-Disposition', 'attachment; filename="' + safeTitle + '.pptx"');
res.send(pptxBuffer);
} catch (err) {
console.error('[PPTX]', err.message);
res.status(500).json({ error: err.message });
}
});
// ── POST /api/admin/learning/preview-slides ──────────────────
// Render Marp markdown to HTML for in-browser preview
router.post('/preview-slides', async function(req, res) {
try {
var { markdown } = req.body;
if (!markdown) return res.status(400).json({ error: 'markdown required' });
var { Marp } = require('@marp-team/marp-core');
var marp = new Marp({ html: false });
var { html, css } = marp.render(markdown);
var fullHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>${css}
body{margin:0;background:#1a1a2e;display:flex;flex-wrap:wrap;gap:16px;padding:24px;justify-content:center;}
section{box-shadow:0 4px 20px rgba(0,0,0,0.4);flex-shrink:0;}
</style></head><body>${html}</body></html>`;
res.setHeader('Content-Type', 'text/html');
res.send(fullHtml);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;