// 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 = `
${templateId ? '' : ''}
Sections
Live Preview
Loading preview...
`; 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]) => `
${info.icon}
${info.label}
`).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 = '
Drag sections from the left panel or click to add them here
'; return; } el.innerHTML = builderSections.map((s, i) => { const info = SECTION_TYPES[s.type] || SECTION_TYPES.custom; return `
${info.icon} ${s.type}
${renderSectionConfig(s, i)}
`; }).join(''); } function renderSectionConfig(section, idx) { const type = section.type; const st = section.styling || {}; let toggles = ''; if (type === 'skills') { toggles = `
Max items:
`; } else if (type === 'experience') { toggles = `
Max items:
`; } else if (type === 'education') { toggles = ` `; } else if (type === 'certifications') { toggles = ` `; } else if (type === 'projects') { toggles = ` `; } else if (type === 'custom') { toggles = `
`; } if (!toggles) return ''; return `
${toggles}
`; } // ============================================================ // 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 = '
Upload a CV to see live preview with real data
'; return; } const c = sampleData.candidate; let html = ''; for (const section of builderSections) { const st = section.styling || {}; switch (section.type) { case 'header': html += `

${c.first_name || ''} ${c.last_name || ''}

`; 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 += `
${contactParts.join(' | ')}
`; break; case 'summary': if (c.summary) html += `

${section.name}

${c.summary}

`; break; case 'experience': let exps = sampleData.experience || []; if (st.max_items) exps = exps.slice(0, st.max_items); html += `

${section.name}

`; for (const e of exps) { html += '
'; html += `

${e.position || ''}${st.show_company !== false && e.company ? ' — ' + e.company : ''}

`; if (st.show_location && e.location) html += `
${e.location}
`; 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 += `
${start} — ${end}
`; } if (e.description) html += `

${e.description}

`; 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 += ''; } } 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 += `
Skills: ${skills.join(', ')}
`; } } html += '
'; } break; case 'skills': let skills = sampleData.skills || []; if (st.max_items) skills = skills.slice(0, st.max_items); html += `

${section.name}

`; 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 += `
${cat}: `; html += items.map(s => { let txt = `${s.skill_name}`; if (st.show_proficiency && s.proficiency) txt += ` (${s.proficiency})`; if (st.show_years && s.years_experience) txt += ` ${s.years_experience}y`; txt += ''; return txt; }).join(''); html += '
'; } } else { html += '
' + skills.map(s => { let txt = `${s.skill_name}`; if (st.show_proficiency && s.proficiency) txt += ` (${s.proficiency})`; if (st.show_years && s.years_experience) txt += ` ${s.years_experience}y`; txt += ''; return txt; }).join('') + '
'; } break; case 'education': html += `

${section.name}

`; for (const e of sampleData.education || []) { html += '
'; html += `

${e.degree || ''}${st.show_field !== false && e.field_of_study ? ' in ' + e.field_of_study : ''}

`; html += `
${e.institution || ''}
`; 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 += `
${start} — ${end}
`; } if (st.show_grade && e.grade) html += `
Grade: ${e.grade}
`; html += '
'; } break; case 'certifications': html += `

${section.name}

`; for (const cert of sampleData.certifications || []) { html += '
'; html += `

${cert.name || ''}

`; if (st.show_issuer && cert.issuer) html += `
${cert.issuer}
`; if (st.show_dates) { const d = cert.issue_date ? formatDate(cert.issue_date) : ''; if (d) html += `
${d}
`; } html += '
'; } break; case 'projects': html += `

${section.name}

Project entries from candidate data

`; break; case 'custom': html += `

${section.name}

${st.content || ''}

`; break; } } el.innerHTML = html || '
Add sections to see preview
'; } // ============================================================ // 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'); } }