From 56abeb9e4d2d42aeab5ad4f63b7054d40fe3b594 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Jul 2026 17:47:14 +0000 Subject: [PATCH] feat: visual drag-and-drop template builder with live preview --- static/app.js | 2 +- static/builder.css | 214 +++++++++++++++++++++ static/builder.js | 461 +++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 5 +- 4 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 static/builder.css create mode 100644 static/builder.js 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 || ''}
- +
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 = ` +
+ + + + + + ${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'); + } +} \ 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

- + +
@@ -105,5 +107,6 @@ + \ No newline at end of file