feat: A4 Gridstack.js template designer + Puppeteer PDF renderer
This commit is contained in:
@@ -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
211
static/designer.css
Normal 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
442
static/designer.js
Normal 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: '♟', 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×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}')">⚙</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: 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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user