feat: A4 Gridstack.js template designer + Puppeteer PDF renderer

This commit is contained in:
root
2026-07-16 07:57:16 +00:00
parent 56abeb9e4d
commit 70735502f2
10 changed files with 1802 additions and 3 deletions

View File

@@ -222,7 +222,8 @@ async function loadTemplates() {
<div class="text-muted text-sm mt-8">${t.description || ''}</div>
</div>
<div class="flex gap-8">
<button class="btn btn-sm btn-outline" onclick="showBuilder('${t.id}')">Edit in Builder</button>
<button class="btn btn-sm btn-outline" onclick="showDesigner('${t.id}')">A4 Designer</button>
<button class="btn btn-sm btn-outline" onclick="showBuilder('${t.id}')">Visual Builder</button>
<button class="btn btn-sm btn-danger" onclick="deleteTemplate('${t.id}')">Delete</button>
</div>
</div>
@@ -541,6 +542,7 @@ async function viewGeneratedCV(id) {
<textarea id="cv-edit-content" style="min-height:500px;font-family:monospace;font-size:13px">${content}</textarea>
<div class="flex gap-8 mt-16">
<button class="btn btn-green" onclick="saveGeneratedCV('${id}')">Save</button>
<button class="btn btn-outline" onclick="downloadGenPDF('${id}')">Download PDF</button>
<select id="cv-status-select" class="btn btn-outline">
<option value="draft" ${data.status === 'draft' ? 'selected' : ''}>Draft</option>
<option value="reviewed" ${data.status === 'reviewed' ? 'selected' : ''}>Reviewed</option>
@@ -576,6 +578,19 @@ async function deleteGeneratedCV(id) {
loadGeneratedCVs();
}
async function downloadGenPDF(genId) {
toast('Rendering PDF...');
try {
const result = await api('/api/generated-cvs/' + genId + '/render-pdf', 'POST');
if (result.pdf_url) {
window.open(result.pdf_url, '_blank');
toast('PDF generated');
}
} catch(e) {
toast('PDF render failed: ' + e.message, 'error');
}
}
// ============================================================
// CHAT
// ============================================================

211
static/designer.css Normal file
View File

@@ -0,0 +1,211 @@
/* A4 Template Designer with Gridstack.js */
@import url('https://cdn.jsdelivr.net/npm/gridstack@11.1.3/gridstack.min.css');
/* Designer layout */
.designer-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 16px;
min-height: 700px;
}
/* Sidebar palette */
.designer-palette {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
height: fit-content;
position: sticky;
top: 0;
}
.designer-palette h3 {
font-size: 13px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.palette-block {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
cursor: grab;
font-size: 13px;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.palette-block:hover {
border-color: var(--accent);
background: rgba(91,141,239,0.08);
}
.palette-block:active { cursor: grabbing; }
.palette-block .icon {
width: 28px; height: 28px;
border-radius: 6px;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
color: white;
}
.palette-block .label { flex: 1; }
.palette-block .desc { font-size: 11px; color: var(--text-muted); }
/* A4 Canvas container */
.designer-canvas-area {
display: flex;
flex-direction: column;
align-items: center;
overflow: auto;
max-height: 800px;
padding: 20px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* The actual A4 page */
.a4-page {
width: 794px;
height: 1123px;
background: white;
position: relative;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
overflow: hidden;
}
/* Gridstack overrides for the A4 canvas */
.a4-page .grid-stack {
background: transparent;
width: 794px;
height: 1123px;
position: relative;
}
.a4-page .grid-stack-item {
border: 1px dashed #5b8def;
background: rgba(91,141,239,0.05);
border-radius: 4px;
overflow: hidden;
}
.a4-page .grid-stack-item:hover {
border-style: solid;
background: rgba(91,141,239,0.1);
}
.a4-page .grid-stack-item-content {
padding: 8px 12px;
font-size: 12px;
color: #333;
font-family: sans-serif;
overflow: hidden;
}
.a4-page .grid-stack-item .block-label {
font-size: 11px;
font-weight: 600;
color: #5b8def;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.a4-page .grid-stack-item .block-content {
font-size: 11px;
color: #666;
overflow: hidden;
max-height: calc(100% - 20px);
}
.a4-page .grid-stack-item .block-config-btn {
background: rgba(91,141,239,0.2);
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
color: #5b8def;
}
/* Gridstack resize handle styling */
.a4-page .grid-stack-item .grid-stack-item-handle {
cursor: grab;
background: rgba(91,141,239,0.1);
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
/* Block type colors */
.block-PersonalDetails { border-left: 3px solid #2c3e50 !important; }
.block-WorkExperience { border-left: 3px solid #27ae60 !important; }
.block-SkillsList { border-left: 3px solid #e67e22 !important; }
.block-Education { border-left: 3px solid #8e44ad !important; }
.block-Certifications { border-left: 3px solid #e74c3c !important; }
.block-ProfessionalSummary { border-left: 3px solid #3498db !important; }
.block-CustomText { border-left: 3px solid #95a5a6 !important; }
.block-Footer { border-left: 3px solid #bdc3c7 !important; }
/* Designer toolbar */
.designer-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.designer-toolbar input {
max-width: 250px;
}
/* Config panel */
.block-config-panel {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
width: 320px;
z-index: 1100;
max-height: 600px;
overflow-y: auto;
display: none;
}
.block-config-panel.active { display: block; }
.block-config-panel h3 {
font-size: 15px;
margin-bottom: 16px;
color: var(--accent);
}
.block-config-panel .form-group { margin-bottom: 12px; }
.block-config-panel label { font-size: 12px; }
/* Preview modal */
.cv-pdf-preview {
width: 100%;
min-height: 600px;
background: white;
border-radius: var(--radius);
}
.cv-pdf-preview iframe {
width: 100%;
height: 700px;
border: none;
border-radius: var(--radius);
}

442
static/designer.js Normal file
View File

@@ -0,0 +1,442 @@
/**
* A4 Template Designer using Gridstack.js
*
* Features:
* - Drag-and-drop block placement on an exact A4 (794x1123px) canvas
* - 12-column grid with row height of 10px for fine positioning
* - Blocks can be moved and resized within canvas boundaries
* - Save exports the clean JSON Template Schema
* - Live preview with real candidate data
* - PDF rendering via the Puppeteer backend
*/
// Block type definitions (palette items)
const BLOCK_TYPES = {
PersonalDetails: { icon: '&#9823;', label: 'Header', desc: 'Name, contact, title', color: '#2c3e50', defaultW: 12, defaultH: 8 },
ProfessionalSummary: { icon: '&#9998;', label: 'Summary', desc: 'Professional summary', color: '#3498db', defaultW: 12, defaultH: 5 },
WorkExperience: { icon: '&#9881;', label: 'Work Experience', desc: 'Job history list', color: '#27ae60', defaultW: 8, defaultH: 20, config: { showAchievements: true, showSkillsUsed: false, maxItems: null } },
SkillsList: { icon: '&#9733;', label: 'Skills', desc: 'Skills by category', color: '#e67e22', defaultW: 4, defaultH: 15, config: { groupByCategory: true, showYears: true, showProficiency: false, sidebar: true, maxItems: null } },
Education: { icon: '&#9961;', label: 'Education', desc: 'Degrees & institutions', color: '#8e44ad', defaultW: 4, defaultH: 10, config: { showDates: true, showField: true, showGrade: false } },
Certifications: { icon: '&#9989;', label: 'Certifications', desc: 'Professional certs', color: '#e74c3c', defaultW: 4, defaultH: 8, config: { showDates: true, showIssuer: true } },
CustomText: { icon: '&#128221;', label: 'Custom Text', desc: 'Free-form text block', color: '#95a5a6', defaultW: 12, defaultH: 5, config: { title: '', content: '' } },
Footer: { icon: '&#9881;', label: 'Footer', desc: 'Footer text', color: '#bdc3c7', defaultW: 12, defaultH: 3, config: { content: 'Generated by CV Application' } }
};
// Designer state
let designerGrid = null;
let designerBlocks = [];
let editingTemplateId = null;
let blockCounter = 0;
// ============================================================
// INITIALIZATION
// ============================================================
function showDesigner(templateId = null) {
editingTemplateId = templateId;
designerBlocks = [];
blockCounter = 0;
// Load template if editing
let loadPromise = Promise.resolve();
let templateName = 'New Template';
let templateDesc = '';
if (templateId) {
loadPromise = api('/api/templates/' + templateId).then(t => {
const structure = typeof t.template_structure === 'string'
? JSON.parse(t.template_structure)
: t.template_structure;
if (structure && structure.blocks) {
designerBlocks = structure.blocks.map(b => ({...b}));
blockCounter = designerBlocks.length;
}
templateName = t.name || templateName;
templateDesc = t.description || '';
});
}
loadPromise.then(() => {
const body = `
<div class="designer-toolbar">
<input type="text" id="designer-name" placeholder="Template name" value="${templateName}">
<input type="text" id="designer-desc" placeholder="Description" value="${templateDesc}" style="max-width:200px">
<button class="btn btn-sm" onclick="saveDesignerTemplate()">Save Template</button>
${templateId ? '<button class="btn btn-sm btn-green" onclick="saveDesignerTemplate(true)">Update</button>' : ''}
<button class="btn btn-sm btn-outline" onclick="previewDesignerPDF()">Preview PDF</button>
<span class="text-muted text-sm" style="margin-left:auto">A4: 794&times;1123px · 12 columns</span>
</div>
<div class="designer-layout">
<div class="designer-palette">
<h3>Template Blocks</h3>
<div id="designer-palette-list"></div>
</div>
<div class="designer-canvas-area">
<div class="a4-page" id="a4-page">
<div class="grid-stack" id="designer-grid"></div>
</div>
</div>
</div>
<div class="block-config-panel" id="block-config-panel"></div>
`;
showModal(body, 'A4 Template Designer', 'large');
// Load Gridstack CSS dynamically
if (!document.getElementById('gridstack-css')) {
const link = document.createElement('link');
link.id = 'gridstack-css';
link.rel = 'stylesheet';
link.href = 'https://cdn.jsdelivr.net/npm/gridstack@11.1.3/gridstack.min.css';
document.head.appendChild(link);
}
// Load Gridstack JS dynamically
if (typeof GridStack === 'undefined') {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/gridstack@11.1.3/gridstack-all-jq.js';
script.onload = () => initGridstack();
document.head.appendChild(script);
} else {
initGridstack();
}
renderDesignerPalette();
});
}
// ============================================================
// GRIDSTACK INITIALIZATION
// ============================================================
function initGridstack() {
// Calculate grid options for A4
// 794px width / 12 columns = ~66.17px per column
// Use 10px row height for fine vertical control (1123/10 = ~112 rows)
const cellHeight = 10;
const numCols = 12;
const numRows = Math.floor(1123 / cellHeight); // ~112 rows
designerGrid = GridStack.init({
column: numCols,
cellHeight: cellHeight + 'px',
maxRow: numRows,
minRow: numRows,
staticGrid: false,
draggable: { handle: '.grid-stack-item-handle' },
resizable: { handles: 'e, se, s, sw, w' },
float: true, // Allow blocks to float (not collapse)
margin: 0,
disableOneColumnMode: true,
}, '#designer-grid');
// Add existing blocks (if editing a template)
for (const block of designerBlocks) {
addBlockToGrid(block, false);
}
// Listen for grid changes
designerGrid.on('change', (event, items) => {
for (const item of items) {
updateBlockFromGridItem(item);
}
});
designerGrid.on('removed', (event, items) => {
for (const item of items) {
removeBlock(item.id);
}
});
// Wire up palette drag-to-canvas
setupPaletteDrag();
}
// ============================================================
// PALETTE
// ============================================================
function renderDesignerPalette() {
const el = document.getElementById('designer-palette-list');
if (!el) return;
el.innerHTML = Object.entries(BLOCK_TYPES).map(([type, info]) => `
<div class="palette-block" data-type="${type}" onclick="addBlockFromPalette('${type}')">
<div class="icon" style="background:${info.color}">${info.icon}</div>
<div>
<div class="label">${info.label}</div>
<div class="desc">${info.desc}</div>
</div>
</div>
`).join('');
}
function setupPaletteDrag() {
// Gridstack native widget drop from sidebar
if (designerGrid) {
designerGrid.removeDraggable('.palette-block');
document.querySelectorAll('.palette-block').forEach(el => {
designerGrid.draggable(el, {
appendTo: '#a4-page',
helper: 'clone',
revert: 'invalid',
handle: 'div',
});
});
}
}
// ============================================================
// BLOCK MANAGEMENT
// ============================================================
function addBlockFromPalette(type) {
const info = BLOCK_TYPES[type];
if (!info) return;
blockCounter++;
const block = {
blockId: `block-${blockCounter}`,
type: type,
title: info.label,
x: 0,
y: 0,
w: info.defaultW,
h: info.defaultH,
config: { ...(info.config || {}) }
};
designerBlocks.push(block);
addBlockToGrid(block, true);
}
function addBlockToGrid(block, isNew) {
if (!designerGrid) return;
const info = BLOCK_TYPES[block.type] || BLOCK_TYPES.CustomText;
const widgetHTML = `
<div class="block-label">
<span>${info.icon} ${block.title || info.label}</span>
<span>
<button class="block-config-btn" onclick="showBlockConfig('${block.blockId}')">&#9881;</button>
<button class="block-config-btn" onclick="deleteBlockFromGrid('${block.blockId}')">&times;</button>
</span>
</div>
<div class="block-content">${getBlockPreviewText(block)}</div>
`;
const widget = designerGrid.addWidget({
id: block.blockId,
x: block.x,
y: block.y,
w: block.w,
h: block.h,
content: widgetHTML,
});
// Add type class for color coding
const el = document.querySelector(`[gs-id="${block.blockId}"]`);
if (el) el.classList.add(`block-${block.type}`);
}
function getBlockPreviewText(block) {
switch (block.type) {
case 'PersonalDetails': return '<strong>John Smith</strong><br>john@email.com | +1-555-123-4567<br>Cape Town, SA';
case 'ProfessionalSummary': return 'Experienced software engineer with 8+ years...';
case 'WorkExperience': return 'Senior Engineer — TechCorp (2022-Present)<br>Software Engineer — DataSoft (2019-2021)';
case 'SkillsList': return '<strong>Programming:</strong> C#, React, Python<br><strong>Cloud:</strong> Azure, Docker';
case 'Education': return 'BSc Computer Science<br>University of Cape Town';
case 'Certifications': return 'Azure Developer Associate<br>Certified Kubernetes App Developer';
case 'CustomText': return block.config?.content || 'Custom text block';
case 'Footer': return block.config?.content || 'Generated by CV Application';
default: return '';
}
}
function updateBlockFromGridItem(item) {
const block = designerBlocks.find(b => b.blockId === item.id);
if (block) {
block.x = item.x;
block.y = item.y;
block.w = item.w;
block.h = item.h;
}
}
function deleteBlockFromGrid(blockId) {
if (!designerGrid) return;
const el = document.querySelector(`[gs-id="${blockId}"]`);
if (el) designerGrid.removeWidget(el);
removeBlock(blockId);
}
function removeBlock(blockId) {
designerBlocks = designerBlocks.filter(b => b.blockId !== blockId);
const panel = document.getElementById('block-config-panel');
if (panel) panel.classList.remove('active');
}
// ============================================================
// BLOCK CONFIGURATION PANEL
// ============================================================
function showBlockConfig(blockId) {
const block = designerBlocks.find(b => b.blockId === blockId);
if (!block) return;
const panel = document.getElementById('block-config-panel');
const info = BLOCK_TYPES[block.type];
const st = block.config || {};
let configFields = '';
// Common: title
configFields += `
<div class="form-group">
<label>Section Title (shown in CV)</label>
<input type="text" value="${block.title || ''}" onchange="updateBlockConfig('${blockId}','title',this.value)">
</div>
`;
// Type-specific config
if (block.type === 'WorkExperience') {
configFields += `
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showAchievements!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showAchievements',this.checked)"> Show achievements</label></div>
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showSkillsUsed?'checked':''} onchange="updateBlockConfig('${blockId}','config.showSkillsUsed',this.checked)"> Show skills used</label></div>
<div class="form-group"><label>Max items (blank = all)</label><input type="number" value="${st.maxItems||''}" placeholder="all" onchange="updateBlockConfig('${blockId}','config.maxItems',this.value?parseInt(this.value):null)"></div>
`;
} else if (block.type === 'SkillsList') {
configFields += `
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.groupByCategory!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.groupByCategory',this.checked)"> Group by category</label></div>
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showYears?'checked':''} onchange="updateBlockConfig('${blockId}','config.showYears',this.checked)"> Show years of experience</label></div>
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showProficiency?'checked':''} onchange="updateBlockConfig('${blockId}','config.showProficiency',this.checked)"> Show proficiency level</label></div>
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.sidebar?'checked':''} onchange="updateBlockConfig('${blockId}','config.sidebar',this.checked)"> Sidebar styling (shaded background)</label></div>
<div class="form-group"><label>Max items (blank = all)</label><input type="number" value="${st.maxItems||''}" placeholder="all" onchange="updateBlockConfig('${blockId}','config.maxItems',this.value?parseInt(this.value):null)"></div>
`;
} else if (block.type === 'Education') {
configFields += `
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showDates!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showDates',this.checked)"> Show dates</label></div>
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showField!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showField',this.checked)"> Show field of study</label></div>
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showGrade?'checked':''} onchange="updateBlockConfig('${blockId}','config.showGrade',this.checked)"> Show grade</label></div>
`;
} else if (block.type === 'Certifications') {
configFields += `
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showDates!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showDates',this.checked)"> Show dates</label></div>
<div class="form-group"><label class="config-toggle"><input type="checkbox" ${st.showIssuer!==false?'checked':''} onchange="updateBlockConfig('${blockId}','config.showIssuer',this.checked)"> Show issuer</label></div>
`;
} else if (block.type === 'CustomText' || block.type === 'Footer') {
configFields += `
<div class="form-group"><label>Content</label><textarea style="min-height:80px" onchange="updateBlockConfig('${blockId}','config.content',this.value)">${st.content||''}</textarea></div>
`;
}
panel.innerHTML = `
<h3>${info.icon} ${info.label} Config</h3>
<p class="text-muted text-sm" style="margin-bottom:12px">Block ID: ${blockId}</p>
${configFields}
<button class="btn btn-sm btn-outline w-full" onclick="document.getElementById('block-config-panel').classList.remove('active')">Close</button>
`;
panel.classList.add('active');
}
function updateBlockConfig(blockId, keyPath, value) {
const block = designerBlocks.find(b => b.blockId === blockId);
if (!block) return;
const keys = keyPath.split('.');
let obj = block;
for (let i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]]) obj[keys[i]] = {};
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
}
// ============================================================
// SERIALIZE / SAVE
// ============================================================
function getTemplateSchema() {
// Read current positions from grid
if (designerGrid) {
const items = designerGrid.save(false); // save without DOM content
for (const item of items) {
const block = designerBlocks.find(b => b.blockId === item.id);
if (block) {
block.x = item.x;
block.y = item.y;
block.w = item.w;
block.h = item.h;
}
}
}
return {
templateId: editingTemplateId || `template_${Date.now()}`,
templateName: document.getElementById('designer-name')?.value || 'Untitled',
canvas: {
width: 794,
height: 1123,
columns: 12
},
blocks: designerBlocks.map(b => ({
blockId: b.blockId,
type: b.type,
title: b.title,
x: b.x,
y: b.y,
w: b.w,
h: b.h,
config: b.config || {}
}))
};
}
async function saveDesignerTemplate(isUpdate = false) {
const schema = getTemplateSchema();
const name = document.getElementById('designer-name')?.value || 'Untitled Template';
const desc = document.getElementById('designer-desc')?.value || '';
try {
if (isUpdate && editingTemplateId) {
await api('/api/templates/' + editingTemplateId, 'PUT', {
name, description: desc, template_structure: schema, styling: ''
});
toast('Template updated');
} else {
await api('/api/templates', 'POST', {
name, description: desc, template_structure: schema, styling: '',
created_by: 'manual'
});
toast('Template saved');
}
closeModal();
loadTemplates();
} catch(e) {
toast('Save failed: ' + e.message, 'error');
}
}
// ============================================================
// PDF PREVIEW
// ============================================================
async function previewDesignerPDF() {
const schema = getTemplateSchema();
// Get first candidate for preview
const candidatesData = await api('/api/candidates?limit=1');
if (!candidatesData.candidates.length) {
toast('Upload a CV first to preview with real data', 'error');
return;
}
const candidateData = await api('/api/candidates/' + candidatesData.candidates[0].id);
toast('Rendering PDF...');
try {
const result = await api('/api/render-pdf', 'POST', {
template: schema,
cv_data: candidateData
});
if (result.pdf_url) {
// Open PDF in new tab
window.open(result.pdf_url, '_blank');
toast('PDF generated');
}
} catch(e) {
toast('PDF render failed: ' + e.message, 'error');
}
}

View File

@@ -6,6 +6,7 @@
<title>CV Application</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/builder.css">
<link rel="stylesheet" href="/static/designer.css">
</head>
<body>
<div class="app">
@@ -65,7 +66,8 @@
<div id="page-templates" class="page">
<h2 style="margin-bottom:20px">CV Templates</h2>
<div class="flex mb-16 gap-8">
<button class="btn" onclick="showBuilder()">+ Visual Builder</button>
<button class="btn" onclick="showDesigner()">+ A4 Designer</button>
<button class="btn btn-outline" onclick="showBuilder()">+ Visual Builder</button>
<button class="btn btn-outline" onclick="showTemplateModal()">+ Manual (JSON)</button>
<button class="btn btn-outline" onclick="showTemplateGenModal()">Generate with AI</button>
</div>
@@ -108,5 +110,6 @@
<script src="/static/app.js"></script>
<script src="/static/builder.js"></script>
<script src="/static/designer.js"></script>
</body>
</html>