Files
cv-app/static/designer.js

442 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 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');
}
}