Files
cv-app/static/builder.js

461 lines
20 KiB
JavaScript

// 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">&#9776;</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">&#9650;</button>
<button onclick="moveSection(${i},1)" title="Move down">&#9660;</button>
<button class="delete-btn" onclick="deleteSection(${i})" title="Delete">&times;</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');
}
}