436 lines
18 KiB
JavaScript
436 lines
18 KiB
JavaScript
/**
|
|
* 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: '♟', label: 'Header', desc: 'Name, contact, title', color: '#2c3e50', defaultW: 12, defaultH: 8 },
|
|
ProfessionalSummary: { icon: '✎', label: 'Summary', desc: 'Professional summary', color: '#3498db', defaultW: 12, defaultH: 5 },
|
|
WorkExperience: { icon: '⚙', label: 'Work Experience', desc: 'Job history list', color: '#27ae60', defaultW: 8, defaultH: 20, config: { showAchievements: true, showSkillsUsed: false, maxItems: null } },
|
|
SkillsList: { icon: '★', 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: '⛩', label: 'Education', desc: 'Degrees & institutions', color: '#8e44ad', defaultW: 4, defaultH: 10, config: { showDates: true, showField: true, showGrade: false } },
|
|
Certifications: { icon: '✅', label: 'Certifications', desc: 'Professional certs', color: '#e74c3c', defaultW: 4, defaultH: 8, config: { showDates: true, showIssuer: true } },
|
|
CustomText: { icon: '📝', label: 'Custom Text', desc: 'Free-form text block', color: '#95a5a6', defaultW: 12, defaultH: 5, config: { title: '', content: '' } },
|
|
Footer: { icon: '⚙', 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 designerEditingTemplateId = null;
|
|
let blockCounter = 0;
|
|
|
|
// ============================================================
|
|
// INITIALIZATION
|
|
// ============================================================
|
|
function showDesigner(templateId = null) {
|
|
designerEditingTemplateId = 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×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');
|
|
|
|
// Gridstack is loaded via <script> in index.html, just init
|
|
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" draggable="true" 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() {
|
|
// Palette items use click-to-add (onclick in renderDesignerPalette)
|
|
// Native HTML5 drag-and-drop is also supported via the palette items' draggable attribute
|
|
document.querySelectorAll('.palette-block').forEach(el => {
|
|
el.addEventListener('dragstart', (e) => {
|
|
const type = el.dataset.type;
|
|
e.dataTransfer.setData('text/plain', type);
|
|
});
|
|
});
|
|
|
|
// Make the canvas accept drops
|
|
const canvas = document.getElementById('a4-page');
|
|
if (canvas) {
|
|
canvas.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
});
|
|
canvas.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
const type = e.dataTransfer.getData('text/plain');
|
|
if (type && BLOCK_TYPES[type]) {
|
|
addBlockFromPalette(type);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 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}')">⚙</button>
|
|
<button class="block-config-btn" onclick="deleteBlockFromGrid('${block.blockId}')">×</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: designerEditingTemplateId || `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 && designerEditingTemplateId) {
|
|
await api('/api/templates/' + designerEditingTemplateId, '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');
|
|
}
|
|
} |