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:
parent
936ad7e8a0
commit
020cc76f2f
6 changed files with 2152 additions and 25 deletions
1800
package-lock.json
generated
1800
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue