diff --git a/static/app.js b/static/app.js
index c938d42..5113c01 100644
--- a/static/app.js
+++ b/static/app.js
@@ -222,7 +222,7 @@ async function loadTemplates() {
${t.description || ''}
- Edit
+ Edit in Builder
Delete
diff --git a/static/builder.css b/static/builder.css
new file mode 100644
index 0000000..3de8662
--- /dev/null
+++ b/static/builder.css
@@ -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;
+}
\ No newline at end of file
diff --git a/static/builder.js b/static/builder.js
new file mode 100644
index 0000000..c555e2d
--- /dev/null
+++ b/static/builder.js
@@ -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 = `
+
+
+
+ + Header
+ + Skills
+ Save Template
+ ${templateId ? 'Update ' : ''}
+
+
+
+
+
+
+
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 `
+
+
+ ${renderSectionConfig(s, i)}
+
+ `;
+ }).join('');
+}
+
+function renderSectionConfig(section, idx) {
+ const type = section.type;
+ const st = section.styling || {};
+ let toggles = '';
+
+ if (type === 'skills') {
+ toggles = `
+ Group by category
+ Show proficiency
+ Show years
+ Max items:
+ `;
+ } else if (type === 'experience') {
+ toggles = `
+ Show duration
+ Show company
+ Show location
+ Show achievements
+ Show skills used
+ Max items:
+ `;
+ } else if (type === 'education') {
+ toggles = `
+ Show dates
+ Show field of study
+ Show grade
+ `;
+ } else if (type === 'certifications') {
+ toggles = `
+ Show dates
+ Show issuer
+ `;
+ } else if (type === 'projects') {
+ toggles = `
+ Show dates
+ Show links
+ `;
+ } else if (type === 'custom') {
+ toggles = `Custom content
`;
+ }
+
+ 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 += '
' + ach.map(a => `${a} `).join('') + ' ';
+ }
+ }
+ 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');
+ }
+}
\ No newline at end of file
diff --git a/static/index.html b/static/index.html
index 2d63b9e..b22e9ee 100644
--- a/static/index.html
+++ b/static/index.html
@@ -5,6 +5,7 @@
CV Application
+
@@ -64,7 +65,8 @@
CV Templates
- + New Template
+ + Visual Builder
+ + Manual (JSON)
Generate with AI
@@ -105,5 +107,6 @@
+