feat: add HTML preview pane for templates via Carbone+LibreOffice
This commit is contained in:
@@ -709,6 +709,67 @@ router.post('/templates/:id/render', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/carbone/templates/:id/preview - Render template with data -> HTML preview
|
||||||
|
router.post('/templates/:id/preview', async (req, res) => {
|
||||||
|
const templateId = req.params.id;
|
||||||
|
const registry = loadRegistry();
|
||||||
|
const template = registry.find(t => t.id === templateId);
|
||||||
|
|
||||||
|
if (!template) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
if (!fs.existsSync(template.path)) return res.status(404).json({ error: 'Template file missing' });
|
||||||
|
|
||||||
|
const candidateData = req.body.candidateData || req.body;
|
||||||
|
const referenceDate = req.body.referenceDate || req.body.generation_date;
|
||||||
|
const carboneData = transformCandidateData(candidateData, referenceDate);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Merge data into DOCX with Carbone
|
||||||
|
const docxBuffer = await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('Carbone merge timed out')), 30000);
|
||||||
|
carbone.render(template.path, carboneData, {}, (err, result) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err) reject(err); else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Convert to HTML with LibreOffice
|
||||||
|
const tmpDir = path.join(__dirname, 'output');
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
const tmpId = uuidv4();
|
||||||
|
const tmpDocx = path.join(tmpDir, `${tmpId}.docx`);
|
||||||
|
const tmpHtml = path.join(tmpDir, `${tmpId}.html`);
|
||||||
|
|
||||||
|
fs.writeFileSync(tmpDocx, docxBuffer);
|
||||||
|
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const htmlPath = await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('LibreOffice conversion timed out')), 60000);
|
||||||
|
execFile('soffice', [
|
||||||
|
'--headless', '--invisible', '--nocrashreport', '--nodefault',
|
||||||
|
'--nologo', '--nofirststartwizard', '--norestore',
|
||||||
|
'--convert-to', 'html', '--outdir', tmpDir, tmpDocx
|
||||||
|
], { timeout: 60000 }, (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err) reject(err);
|
||||||
|
else if (!fs.existsSync(tmpHtml)) reject(new Error('HTML was not generated'));
|
||||||
|
else resolve(tmpHtml);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const htmlBuffer = fs.readFileSync(htmlPath);
|
||||||
|
|
||||||
|
// Clean up temp files
|
||||||
|
try { fs.unlinkSync(tmpDocx); } catch {}
|
||||||
|
try { fs.unlinkSync(tmpHtml); } catch {}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html');
|
||||||
|
res.send(htmlBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Preview error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Preview failed: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /api/carbone/templates/:id - Delete a template
|
// DELETE /api/carbone/templates/:id - Delete a template
|
||||||
router.delete('/templates/:id', (req, res) => {
|
router.delete('/templates/:id', (req, res) => {
|
||||||
const templateId = req.params.id;
|
const templateId = req.params.id;
|
||||||
|
|||||||
Binary file not shown.
@@ -6,6 +6,6 @@
|
|||||||
"originalName": "master_cv_template.docx",
|
"originalName": "master_cv_template.docx",
|
||||||
"path": "/root/workspace/cv-app/renderer/carbone-templates/433a0593-d3cf-414e-8763-ab5f87cbc75c.docx",
|
"path": "/root/workspace/cv-app/renderer/carbone-templates/433a0593-d3cf-414e-8763-ab5f87cbc75c.docx",
|
||||||
"uploadedAt": "2026-07-16T09:03:29.601Z",
|
"uploadedAt": "2026-07-16T09:03:29.601Z",
|
||||||
"updatedAt": "2026-07-17T09:07:36.233Z"
|
"updatedAt": "2026-07-17T13:05:53.142Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -96,7 +96,7 @@ async function loadCarboneTemplates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// RENDER SAMPLE CV
|
// RENDER SAMPLE CV (download PDF)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
async function renderSampleCV(templateId) {
|
async function renderSampleCV(templateId) {
|
||||||
toast('Generating sample CV PDF...');
|
toast('Generating sample CV PDF...');
|
||||||
@@ -137,6 +137,49 @@ async function renderSampleCV(templateId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PREVIEW TEMPLATE (inline HTML preview)
|
||||||
|
// ============================================================
|
||||||
|
async function previewTemplate(templateId) {
|
||||||
|
toast('Generating preview...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get first candidate for sample data
|
||||||
|
const candidatesResp = await fetch('/api/candidates?limit=1');
|
||||||
|
const candidatesData = await candidatesResp.json();
|
||||||
|
let cvData = null;
|
||||||
|
|
||||||
|
if (candidatesData.candidates && candidatesData.candidates.length > 0) {
|
||||||
|
const fullResp = await fetch('/api/candidates/' + candidatesData.candidates[0].id);
|
||||||
|
cvData = await fullResp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cvData) {
|
||||||
|
toast('Upload a CV first to preview', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(CARBONE_API + '/templates/' + templateId + '/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ candidateData: cvData })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.error || 'Preview failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await resp.text();
|
||||||
|
// Show the HTML in a modal with an iframe
|
||||||
|
const body = '<iframe style="width:100%;height:700px;border:1px solid var(--border);border-radius:8px" srcdoc="' + html.replace(/"/g, '"') + '"></iframe>';
|
||||||
|
showModal(body, 'Template Preview', 'large');
|
||||||
|
toast('Preview generated');
|
||||||
|
} catch (e) {
|
||||||
|
toast('Preview failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// DELETE TEMPLATE
|
// DELETE TEMPLATE
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -250,7 +293,8 @@ async function loadOnlyofficeTemplates() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-8">
|
<div class="flex gap-8">
|
||||||
<button class="btn btn-sm" onclick="openOnlyofficeEditor('${t.id}', '${t.name.replace(/'/g, "\\'")}')">Edit Online</button>
|
<button class="btn btn-sm" onclick="openOnlyofficeEditor('${t.id}', '${t.name.replace(/'/g, "\\'")}')">Edit Online</button>
|
||||||
<button class="btn btn-sm btn-outline" onclick="renderSampleCV('${t.id}')">Preview PDF</button>
|
<button class="btn btn-sm btn-outline" onclick="previewTemplate('${t.id}')">Preview</button>
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="renderSampleCV('${t.id}')">Download PDF</button>
|
||||||
<button class="btn btn-sm btn-danger" onclick="deleteCarboneTemplate('${t.id}')">Delete</button>
|
<button class="btn btn-sm btn-danger" onclick="deleteCarboneTemplate('${t.id}')">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,6 +140,6 @@
|
|||||||
<div class="modal-overlay" id="modal-overlay"></div>
|
<div class="modal-overlay" id="modal-overlay"></div>
|
||||||
|
|
||||||
<script src="/static/app.js?v=15"></script>
|
<script src="/static/app.js?v=15"></script>
|
||||||
<script src="/static/carbone.js?v=14"></script>
|
<script src="/static/carbone.js?v=15"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user