461 lines
20 KiB
JavaScript
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">☰</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');
|
|
}
|
|
} |