feat: visual drag-and-drop template builder with live preview

This commit is contained in:
root
2026-07-15 17:47:14 +00:00
parent ce2a59f986
commit 56abeb9e4d
4 changed files with 680 additions and 2 deletions

View File

@@ -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
View 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
View File

@@ -0,0 +1,461 @@
// Template Builder - Visual drag-and-drop no-code editor
const SECTION_TYPES = {
header: { icon: '&#9823;', label: 'Header', fields: ['name','email','phone','address','linkedin','github','website'], defaults: {} },
summary: { icon: '&#9998;', label: 'Professional Summary', fields: [], defaults: {} },
experience: { icon: '&#9881;', 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: '&#9733;', label: 'Skills', fields: [], defaults: { group_by_category: true, show_proficiency: false, show_years: true, max_items: null } },
education: { icon: '&#9961;', label: 'Education', fields: [], defaults: { show_dates: true, show_grade: false, show_field: true } },
certifications: { icon: '&#9989;', label: 'Certifications', fields: [], defaults: { show_dates: true, show_issuer: true } },
projects: { icon: '&#128193;', label: 'Projects', fields: [], defaults: { show_dates: true, show_link: false } },
custom: { icon: '&#128221;', 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');
}
}

View File

@@ -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>