feat: visual drag-and-drop template builder with live preview
This commit is contained in:
@@ -222,7 +222,7 @@ 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="editTemplate('${t.id}')">Edit</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="showBuilder('${t.id}')">Edit in Builder</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteTemplate('${t.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
214
static/builder.css
Normal file
214
static/builder.css
Normal file
@@ -0,0 +1,214 @@
|
||||
/* Template Builder - Visual drag-and-drop */
|
||||
.builder-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 400px;
|
||||
gap: 16px;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
/* Palette */
|
||||
.builder-palette {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.palette-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.palette-item {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: grab;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.palette-item:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(91,141,239,0.1);
|
||||
}
|
||||
.palette-item:active { cursor: grabbing; }
|
||||
.palette-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;
|
||||
}
|
||||
|
||||
/* Builder canvas */
|
||||
.builder-canvas {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
min-height: 600px;
|
||||
}
|
||||
.canvas-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.canvas-section {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
cursor: move;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.canvas-section:hover { border-color: var(--accent); }
|
||||
.canvas-section.dragging { opacity: 0.4; border-color: var(--accent); }
|
||||
.canvas-section.drag-over { border-top: 3px solid var(--accent); }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.section-handle {
|
||||
color: var(--text-muted);
|
||||
cursor: grab;
|
||||
font-size: 18px;
|
||||
}
|
||||
.section-type-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(91,141,239,0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
.section-name-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
.section-name-input:focus { border-bottom-color: var(--accent); outline: none; }
|
||||
.section-actions { display: flex; gap: 4px; }
|
||||
.section-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.section-actions button:hover { color: var(--text); background: var(--border); }
|
||||
.section-actions .delete-btn:hover { color: var(--red); }
|
||||
|
||||
/* Section config */
|
||||
.section-config {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.config-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
.config-toggle input { width: auto; }
|
||||
.config-toggle:hover { color: var(--text); }
|
||||
.config-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.config-number input { width: 60px; }
|
||||
|
||||
/* Preview */
|
||||
.builder-preview {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
max-height: 700px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.preview-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.preview-cv {
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 30px;
|
||||
border-radius: 6px;
|
||||
font-family: 'Georgia', serif;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
min-height: 400px;
|
||||
}
|
||||
.preview-cv h1 { font-size: 22px; color: #1a1a1a; margin-bottom: 4px; }
|
||||
.preview-cv h2 { font-size: 15px; color: #333; border-bottom: 1px solid #ddd; padding-bottom: 4px; margin-top: 16px; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.preview-cv h3 { font-size: 14px; color: #444; margin-bottom: 2px; }
|
||||
.preview-cv .label { font-size: 11px; color: #888; text-transform: uppercase; }
|
||||
.preview-cv .skill-group { margin-bottom: 6px; }
|
||||
.preview-cv .skill-cat { font-weight: 600; font-size: 12px; color: #555; }
|
||||
.preview-cv .skill-item { display: inline; font-size: 13px; }
|
||||
.preview-cv .skill-item::after { content: " · "; color: #aaa; }
|
||||
.preview-cv .skill-item:last-child::after { content: ""; }
|
||||
.preview-cv .years { font-size: 11px; color: #888; }
|
||||
.preview-cv .exp-entry { margin-bottom: 10px; }
|
||||
.preview-cv .exp-date { font-size: 12px; color: #888; }
|
||||
|
||||
/* Builder toolbar */
|
||||
.builder-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.builder-toolbar input {
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Drop zone hint */
|
||||
.drop-hint {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
461
static/builder.js
Normal file
461
static/builder.js
Normal file
@@ -0,0 +1,461 @@
|
||||
// Template Builder - Visual drag-and-drop no-code editor
|
||||
|
||||
const SECTION_TYPES = {
|
||||
header: { icon: '♟', label: 'Header', fields: ['name','email','phone','address','linkedin','github','website'], defaults: {} },
|
||||
summary: { icon: '✎', label: 'Professional Summary', fields: [], defaults: {} },
|
||||
experience: { icon: '⚙', label: 'Work Experience', fields: [], defaults: { show_years: true, max_items: null, show_company: true, show_location: true, show_achievements: true, show_skills_used: true } },
|
||||
skills: { icon: '★', label: 'Skills', fields: [], defaults: { group_by_category: true, show_proficiency: false, show_years: true, max_items: null } },
|
||||
education: { icon: '⛩', label: 'Education', fields: [], defaults: { show_dates: true, show_grade: false, show_field: true } },
|
||||
certifications: { icon: '✅', label: 'Certifications', fields: [], defaults: { show_dates: true, show_issuer: true } },
|
||||
projects: { icon: '📁', label: 'Projects', fields: [], defaults: { show_dates: true, show_link: false } },
|
||||
custom: { icon: '📝', label: 'Custom Text', fields: [], defaults: {} }
|
||||
};
|
||||
|
||||
let builderSections = [];
|
||||
let dragSourceIdx = null;
|
||||
let editingTemplateId = null;
|
||||
|
||||
// ============================================================
|
||||
// BUILDER UI
|
||||
// ============================================================
|
||||
function showBuilder(templateId = null) {
|
||||
editingTemplateId = templateId;
|
||||
builderSections = [];
|
||||
|
||||
if (templateId) {
|
||||
// Load existing template
|
||||
api('/api/templates/' + templateId).then(t => {
|
||||
builderSections = (t.template_structure && t.template_structure.sections) || [];
|
||||
if (typeof builderSections === 'string') builderSections = JSON.parse(builderSections);
|
||||
document.getElementById('builder-template-name').value = t.name || '';
|
||||
document.getElementById('builder-template-desc').value = t.description || '';
|
||||
renderCanvas();
|
||||
renderPreview();
|
||||
});
|
||||
} else {
|
||||
// Start with empty or default sections
|
||||
builderSections = [
|
||||
{ name: 'Header', type: 'header', order: 1, fields: SECTION_TYPES.header.fields, styling: {} },
|
||||
{ name: 'Professional Summary', type: 'summary', order: 2, fields: [], styling: {} },
|
||||
{ name: 'Work Experience', type: 'experience', order: 3, fields: [], styling: { show_years: true, show_achievements: true, show_skills_used: true } },
|
||||
{ name: 'Skills', type: 'skills', order: 4, fields: [], styling: { group_by_category: true, show_years: true } },
|
||||
{ name: 'Education', type: 'education', order: 5, fields: [], styling: { show_dates: true, show_field: true } },
|
||||
{ name: 'Certifications', type: 'certifications', order: 6, fields: [], styling: { show_dates: true, show_issuer: true } }
|
||||
];
|
||||
renderCanvas();
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
// Load sample data for preview
|
||||
loadSamplePreviewData();
|
||||
|
||||
const body = `
|
||||
<div class="builder-toolbar">
|
||||
<input type="text" id="builder-template-name" placeholder="Template name" value="${templateId ? '' : 'Standard Tech CV'}">
|
||||
<input type="text" id="builder-template-desc" placeholder="Description (optional)">
|
||||
<button class="btn btn-sm btn-outline" onclick="builderAddFromPalette('header')">+ Header</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="builderAddFromPalette('skills')">+ Skills</button>
|
||||
<button class="btn btn-sm" onclick="saveBuilderTemplate()">Save Template</button>
|
||||
${templateId ? '<button class="btn btn-sm btn-green" onclick="saveBuilderTemplate(true)">Update</button>' : ''}
|
||||
</div>
|
||||
<div class="builder-layout">
|
||||
<div class="builder-palette">
|
||||
<div class="palette-title">Sections</div>
|
||||
<div id="palette-list"></div>
|
||||
</div>
|
||||
<div class="builder-canvas" id="builder-canvas"></div>
|
||||
<div>
|
||||
<div class="builder-preview">
|
||||
<div class="preview-title">Live Preview</div>
|
||||
<div class="preview-cv" id="builder-preview-cv">Loading preview...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
showModal(body, 'Visual Template Builder', 'large');
|
||||
renderPalette();
|
||||
renderCanvas();
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PALETTE
|
||||
// ============================================================
|
||||
function renderPalette() {
|
||||
const el = document.getElementById('palette-list');
|
||||
if (!el) return;
|
||||
el.innerHTML = Object.entries(SECTION_TYPES).map(([type, info]) => `
|
||||
<div class="palette-item" draggable="true" ondragstart="paletteDragStart(event,'${type}')" onclick="builderAddFromPalette('${type}')">
|
||||
<div class="palette-icon">${info.icon}</div>
|
||||
<span>${info.label}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function paletteDragStart(event, type) {
|
||||
event.dataTransfer.setData('text/plain', JSON.stringify({ action: 'add', type }));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CANVAS (section list with drag reorder)
|
||||
// ============================================================
|
||||
function builderAddFromPalette(type) {
|
||||
const info = SECTION_TYPES[type];
|
||||
builderSections.push({
|
||||
name: info.label,
|
||||
type: type,
|
||||
order: builderSections.length + 1,
|
||||
fields: info.fields ? [...info.fields] : [],
|
||||
styling: { ...(info.defaults || {}) }
|
||||
});
|
||||
renderCanvas();
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
const el = document.getElementById('builder-canvas');
|
||||
if (!el) return;
|
||||
|
||||
if (builderSections.length === 0) {
|
||||
el.innerHTML = '<div class="canvas-empty">Drag sections from the left panel or click to add them here</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = builderSections.map((s, i) => {
|
||||
const info = SECTION_TYPES[s.type] || SECTION_TYPES.custom;
|
||||
return `
|
||||
<div class="canvas-section" draggable="true" ondragstart="canvasDragStart(${i})" ondragover="canvasDragOver(event,${i})" ondrop="canvasDrop(event,${i})" ondragend="canvasDragEnd()" id="section-${i}">
|
||||
<div class="section-header">
|
||||
<span class="section-handle">☰</span>
|
||||
<span class="palette-icon" style="width:24px;height:24px;font-size:12px">${info.icon}</span>
|
||||
<input class="section-name-input" value="${s.name}" onchange="updateSectionName(${i}, this.value)">
|
||||
<span class="section-type-badge">${s.type}</span>
|
||||
<div class="section-actions">
|
||||
<button onclick="moveSection(${i},-1)" title="Move up">▲</button>
|
||||
<button onclick="moveSection(${i},1)" title="Move down">▼</button>
|
||||
<button class="delete-btn" onclick="deleteSection(${i})" title="Delete">×</button>
|
||||
</div>
|
||||
</div>
|
||||
${renderSectionConfig(s, i)}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSectionConfig(section, idx) {
|
||||
const type = section.type;
|
||||
const st = section.styling || {};
|
||||
let toggles = '';
|
||||
|
||||
if (type === 'skills') {
|
||||
toggles = `
|
||||
<label class="config-toggle"><input type="checkbox" ${st.group_by_category ? 'checked':''} onchange="updateStyling(${idx},'group_by_category',this.checked)"> Group by category</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_proficiency ? 'checked':''} onchange="updateStyling(${idx},'show_proficiency',this.checked)"> Show proficiency</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_years ? 'checked':''} onchange="updateStyling(${idx},'show_years',this.checked)"> Show years</label>
|
||||
<div class="config-number">Max items: <input type="number" value="${st.max_items||''}" placeholder="all" onchange="updateStyling(${idx},'max_items',this.value?parseInt(this.value):null)"></div>
|
||||
`;
|
||||
} else if (type === 'experience') {
|
||||
toggles = `
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_years!==false?'checked':''} onchange="updateStyling(${idx},'show_years',this.checked)"> Show duration</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_company!==false?'checked':''} onchange="updateStyling(${idx},'show_company',this.checked)"> Show company</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_location?'checked':''} onchange="updateStyling(${idx},'show_location',this.checked)"> Show location</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_achievements!==false?'checked':''} onchange="updateStyling(${idx},'show_achievements',this.checked)"> Show achievements</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_skills_used?'checked':''} onchange="updateStyling(${idx},'show_skills_used',this.checked)"> Show skills used</label>
|
||||
<div class="config-number">Max items: <input type="number" value="${st.max_items||''}" placeholder="all" onchange="updateStyling(${idx},'max_items',this.value?parseInt(this.value):null)"></div>
|
||||
`;
|
||||
} else if (type === 'education') {
|
||||
toggles = `
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_dates!==false?'checked':''} onchange="updateStyling(${idx},'show_dates',this.checked)"> Show dates</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_field!==false?'checked':''} onchange="updateStyling(${idx},'show_field',this.checked)"> Show field of study</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_grade?'checked':''} onchange="updateStyling(${idx},'show_grade',this.checked)"> Show grade</label>
|
||||
`;
|
||||
} else if (type === 'certifications') {
|
||||
toggles = `
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_dates!==false?'checked':''} onchange="updateStyling(${idx},'show_dates',this.checked)"> Show dates</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_issuer!==false?'checked':''} onchange="updateStyling(${idx},'show_issuer',this.checked)"> Show issuer</label>
|
||||
`;
|
||||
} else if (type === 'projects') {
|
||||
toggles = `
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_dates?'checked':''} onchange="updateStyling(${idx},'show_dates',this.checked)"> Show dates</label>
|
||||
<label class="config-toggle"><input type="checkbox" ${st.show_link?'checked':''} onchange="updateStyling(${idx},'show_link',this.checked)"> Show links</label>
|
||||
`;
|
||||
} else if (type === 'custom') {
|
||||
toggles = `<div style="grid-column:1/-1"><label class="text-muted text-sm">Custom content</label><textarea style="min-height:60px" onchange="updateStyling(${idx},'content',this.value)" placeholder="Enter custom text for this section...">${st.content||''}</textarea></div>`;
|
||||
}
|
||||
|
||||
if (!toggles) return '';
|
||||
|
||||
return `<div class="section-config">${toggles}</div>`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DRAG & DROP
|
||||
// ============================================================
|
||||
function canvasDragStart(idx) {
|
||||
dragSourceIdx = idx;
|
||||
const el = document.getElementById('section-' + idx);
|
||||
if (el) el.classList.add('dragging');
|
||||
}
|
||||
|
||||
function canvasDragOver(event, idx) {
|
||||
event.preventDefault();
|
||||
if (dragSourceIdx !== null && dragSourceIdx !== idx) {
|
||||
document.querySelectorAll('.canvas-section').forEach(e => e.classList.remove('drag-over'));
|
||||
const el = document.getElementById('section-' + idx);
|
||||
if (el) el.classList.add('drag-over');
|
||||
}
|
||||
}
|
||||
|
||||
function canvasDrop(event, targetIdx) {
|
||||
event.preventDefault();
|
||||
const data = event.dataTransfer.getData('text/plain');
|
||||
|
||||
if (data && data.startsWith('{')) {
|
||||
// Drop from palette
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.action === 'add' && parsed.type) {
|
||||
builderAddFromPalette(parsed.type);
|
||||
// Move to the target position
|
||||
const justAdded = builderSections.length - 1;
|
||||
if (justAdded !== targetIdx) {
|
||||
const item = builderSections.pop();
|
||||
builderSections.splice(targetIdx, 0, item);
|
||||
}
|
||||
reorderSections();
|
||||
renderCanvas();
|
||||
renderPreview();
|
||||
}
|
||||
} else if (dragSourceIdx !== null && dragSourceIdx !== targetIdx) {
|
||||
// Reorder
|
||||
const item = builderSections.splice(dragSourceIdx, 1)[0];
|
||||
builderSections.splice(targetIdx, 0, item);
|
||||
reorderSections();
|
||||
renderCanvas();
|
||||
renderPreview();
|
||||
}
|
||||
dragSourceIdx = null;
|
||||
}
|
||||
|
||||
function canvasDragEnd() {
|
||||
document.querySelectorAll('.canvas-section').forEach(e => {
|
||||
e.classList.remove('dragging', 'drag-over');
|
||||
});
|
||||
dragSourceIdx = null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECTION OPERATIONS
|
||||
// ============================================================
|
||||
function updateSectionName(idx, name) {
|
||||
builderSections[idx].name = name;
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function updateStyling(idx, key, value) {
|
||||
if (!builderSections[idx].styling) builderSections[idx].styling = {};
|
||||
builderSections[idx].styling[key] = value;
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function moveSection(idx, dir) {
|
||||
const newIdx = idx + dir;
|
||||
if (newIdx < 0 || newIdx >= builderSections.length) return;
|
||||
const item = builderSections.splice(idx, 1)[0];
|
||||
builderSections.splice(newIdx, 0, item);
|
||||
reorderSections();
|
||||
renderCanvas();
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function deleteSection(idx) {
|
||||
builderSections.splice(idx, 1);
|
||||
reorderSections();
|
||||
renderCanvas();
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function reorderSections() {
|
||||
builderSections.forEach((s, i) => s.order = i + 1);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LIVE PREVIEW
|
||||
// ============================================================
|
||||
let sampleData = null;
|
||||
|
||||
function loadSamplePreviewData() {
|
||||
// Use the first parsed candidate as sample data
|
||||
api('/api/candidates?limit=1').then(data => {
|
||||
if (data.candidates.length > 0) {
|
||||
api('/api/candidates/' + data.candidates[0].id).then(c => {
|
||||
sampleData = c;
|
||||
renderPreview();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const el = document.getElementById('builder-preview-cv');
|
||||
if (!el) return;
|
||||
|
||||
if (!sampleData) {
|
||||
el.innerHTML = '<div style="color:#999;text-align:center;padding:40px">Upload a CV to see live preview with real data</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const c = sampleData.candidate;
|
||||
let html = '';
|
||||
|
||||
for (const section of builderSections) {
|
||||
const st = section.styling || {};
|
||||
switch (section.type) {
|
||||
case 'header':
|
||||
html += `<h1>${c.first_name || ''} ${c.last_name || ''}</h1>`;
|
||||
const contactParts = [];
|
||||
if (c.email) contactParts.push(c.email);
|
||||
if (c.phone) contactParts.push(c.phone);
|
||||
if (c.address) contactParts.push(c.address);
|
||||
if (c.linkedin) contactParts.push(c.linkedin);
|
||||
if (c.github) contactParts.push(c.github);
|
||||
if (c.website) contactParts.push(c.website);
|
||||
if (contactParts.length) html += `<div class="label">${contactParts.join(' | ')}</div>`;
|
||||
break;
|
||||
|
||||
case 'summary':
|
||||
if (c.summary) html += `<h2>${section.name}</h2><p>${c.summary}</p>`;
|
||||
break;
|
||||
|
||||
case 'experience':
|
||||
let exps = sampleData.experience || [];
|
||||
if (st.max_items) exps = exps.slice(0, st.max_items);
|
||||
html += `<h2>${section.name}</h2>`;
|
||||
for (const e of exps) {
|
||||
html += '<div class="exp-entry">';
|
||||
html += `<h3>${e.position || ''}${st.show_company !== false && e.company ? ' — ' + e.company : ''}</h3>`;
|
||||
if (st.show_location && e.location) html += `<div class="label">${e.location}</div>`;
|
||||
if (st.show_years !== false) {
|
||||
const start = e.start_date ? formatDate(e.start_date) : '';
|
||||
const end = e.end_date ? formatDate(e.end_date) : 'Present';
|
||||
html += `<div class="exp-date">${start} — ${end}</div>`;
|
||||
}
|
||||
if (e.description) html += `<p>${e.description}</p>`;
|
||||
if (st.show_achievements !== false && e.achievements) {
|
||||
const ach = typeof e.achievements === 'string' ? JSON.parse(e.achievements) : e.achievements;
|
||||
if (Array.isArray(ach) && ach.length) {
|
||||
html += '<ul>' + ach.map(a => `<li>${a}</li>`).join('') + '</ul>';
|
||||
}
|
||||
}
|
||||
if (st.show_skills_used && e.skills_used) {
|
||||
const skills = typeof e.skills_used === 'string' ? JSON.parse(e.skills_used) : e.skills_used;
|
||||
if (Array.isArray(skills) && skills.length) {
|
||||
html += `<div class="label">Skills: ${skills.join(', ')}</div>`;
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'skills':
|
||||
let skills = sampleData.skills || [];
|
||||
if (st.max_items) skills = skills.slice(0, st.max_items);
|
||||
html += `<h2>${section.name}</h2>`;
|
||||
if (st.group_by_category) {
|
||||
const groups = {};
|
||||
for (const s of skills) {
|
||||
const cat = s.skill_category || 'Other';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(s);
|
||||
}
|
||||
for (const [cat, items] of Object.entries(groups)) {
|
||||
html += `<div class="skill-group"><span class="skill-cat">${cat}:</span> `;
|
||||
html += items.map(s => {
|
||||
let txt = `<span class="skill-item">${s.skill_name}`;
|
||||
if (st.show_proficiency && s.proficiency) txt += ` (${s.proficiency})`;
|
||||
if (st.show_years && s.years_experience) txt += ` <span class="years">${s.years_experience}y</span>`;
|
||||
txt += '</span>';
|
||||
return txt;
|
||||
}).join('');
|
||||
html += '</div>';
|
||||
}
|
||||
} else {
|
||||
html += '<div>' + skills.map(s => {
|
||||
let txt = `<span class="skill-item">${s.skill_name}`;
|
||||
if (st.show_proficiency && s.proficiency) txt += ` (${s.proficiency})`;
|
||||
if (st.show_years && s.years_experience) txt += ` <span class="years">${s.years_experience}y</span>`;
|
||||
txt += '</span>';
|
||||
return txt;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'education':
|
||||
html += `<h2>${section.name}</h2>`;
|
||||
for (const e of sampleData.education || []) {
|
||||
html += '<div class="exp-entry">';
|
||||
html += `<h3>${e.degree || ''}${st.show_field !== false && e.field_of_study ? ' in ' + e.field_of_study : ''}</h3>`;
|
||||
html += `<div class="label">${e.institution || ''}</div>`;
|
||||
if (st.show_dates) {
|
||||
const start = e.start_date ? formatDate(e.start_date) : '';
|
||||
const end = e.end_date ? formatDate(e.end_date) : '';
|
||||
if (start || end) html += `<div class="exp-date">${start} — ${end}</div>`;
|
||||
}
|
||||
if (st.show_grade && e.grade) html += `<div class="label">Grade: ${e.grade}</div>`;
|
||||
html += '</div>';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'certifications':
|
||||
html += `<h2>${section.name}</h2>`;
|
||||
for (const cert of sampleData.certifications || []) {
|
||||
html += '<div class="exp-entry">';
|
||||
html += `<h3>${cert.name || ''}</h3>`;
|
||||
if (st.show_issuer && cert.issuer) html += `<div class="label">${cert.issuer}</div>`;
|
||||
if (st.show_dates) {
|
||||
const d = cert.issue_date ? formatDate(cert.issue_date) : '';
|
||||
if (d) html += `<div class="exp-date">${d}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'projects':
|
||||
html += `<h2>${section.name}</h2><p style="color:#999">Project entries from candidate data</p>`;
|
||||
break;
|
||||
|
||||
case 'custom':
|
||||
html += `<h2>${section.name}</h2><p>${st.content || ''}</p>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
el.innerHTML = html || '<div style="color:#999;text-align:center;padding:40px">Add sections to see preview</div>';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SAVE TEMPLATE
|
||||
// ============================================================
|
||||
async function saveBuilderTemplate(isUpdate = false) {
|
||||
const name = document.getElementById('builder-template-name').value || 'Untitled Template';
|
||||
const desc = document.getElementById('builder-template-desc').value || '';
|
||||
const structure = { sections: builderSections, styling: '' };
|
||||
|
||||
try {
|
||||
if (isUpdate && editingTemplateId) {
|
||||
await api('/api/templates/' + editingTemplateId, 'PUT', {
|
||||
name, description: desc, template_structure: structure, styling: ''
|
||||
});
|
||||
toast('Template updated');
|
||||
} else {
|
||||
await api('/api/templates', 'POST', {
|
||||
name, description: desc, template_structure: structure, styling: '',
|
||||
created_by: 'manual'
|
||||
});
|
||||
toast('Template created');
|
||||
}
|
||||
closeModal();
|
||||
loadTemplates();
|
||||
} catch(e) {
|
||||
toast('Save failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CV Application</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/builder.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -64,7 +65,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="showTemplateModal()">+ New Template</button>
|
||||
<button class="btn" 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>
|
||||
<div id="templates-list"></div>
|
||||
@@ -105,5 +107,6 @@
|
||||
<div class="modal-overlay" id="modal-overlay"></div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/builder.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user