diff --git a/README.md b/README.md index 8aee7d6..91983b6 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,13 @@ Adapt paths to your chosen structure. This repo currently contains planning docu - At least two templates with instant switching by M6 - Accessibility AA and test coverage by M7 +## User Journey +- User starts by selecting a template from the gallery. +- They then navigate through the stepper to fill out their personal information, work experience, education, skills, and summary. +- Photo upload should only show if the selected template has a placeholder for a profile photo. +- After completing the steps, they can preview their CV in real-time with the selected template. +- If they are satisfied with the preview, they can export their CV as a PDF file. + ## Glossary - ATS: Applicant Tracking System; favors semantic, minimal styling. - Parity: Inline preview and exported PDF display equivalent content/layout. diff --git a/cv-engine/src/assets/templates/ats.svg b/cv-engine/src/assets/templates/ats.svg new file mode 100644 index 0000000..4efd718 --- /dev/null +++ b/cv-engine/src/assets/templates/ats.svg @@ -0,0 +1,10 @@ + + + + + + + + + ATS-Friendly + \ No newline at end of file diff --git a/cv-engine/src/assets/templates/classic.svg b/cv-engine/src/assets/templates/classic.svg new file mode 100644 index 0000000..ecd4302 --- /dev/null +++ b/cv-engine/src/assets/templates/classic.svg @@ -0,0 +1,10 @@ + + + + + + + + + Classic + \ No newline at end of file diff --git a/cv-engine/src/assets/templates/minimal.svg b/cv-engine/src/assets/templates/minimal.svg new file mode 100644 index 0000000..744aeba --- /dev/null +++ b/cv-engine/src/assets/templates/minimal.svg @@ -0,0 +1,9 @@ + + + + + + + + Minimal + \ No newline at end of file diff --git a/cv-engine/src/assets/templates/modern.svg b/cv-engine/src/assets/templates/modern.svg new file mode 100644 index 0000000..f8c3df2 --- /dev/null +++ b/cv-engine/src/assets/templates/modern.svg @@ -0,0 +1,11 @@ + + + + + + + + + + Modern + \ No newline at end of file diff --git a/cv-engine/src/components/ExportControls.tsx b/cv-engine/src/components/ExportControls.tsx index b5a01ef..c717cdd 100644 --- a/cv-engine/src/components/ExportControls.tsx +++ b/cv-engine/src/components/ExportControls.tsx @@ -1,5 +1,6 @@ import React, { useRef, useState } from 'react'; import { useCvStore } from '../store/cvStore'; +import { templatesRegistry } from '../templates/registry'; import { exportPdf, requestExportJob, pollJobStatus, downloadJob, cancelJob } from '../api/export'; interface ExportControlsProps { @@ -87,8 +88,9 @@ const ExportControls: React.FC = ({ endpoint = 'http://loca onChange={e => updateTemplateId(e.target.value)} disabled={status === 'exporting'} > - - + {templatesRegistry.map(t => ( + + ))} diff --git a/cv-engine/src/components/PreviewPanel.tsx b/cv-engine/src/components/PreviewPanel.tsx index 043345f..671c86d 100644 --- a/cv-engine/src/components/PreviewPanel.tsx +++ b/cv-engine/src/components/PreviewPanel.tsx @@ -1,9 +1,9 @@ import React, { useMemo, useState } from 'react'; import { useCvStore } from '../store/cvStore'; -import ATSTemplate from '../templates/ATSTemplate'; -import ClassicTemplate from '../templates/ClassicTemplate'; +import { templatesMap } from '../templates/registry'; import { buildPrintableHtml } from '../utils/printable'; import ExportControls from './ExportControls'; +import TemplateGallery from './TemplateGallery'; interface PreviewPanelProps { className?: string; @@ -13,7 +13,7 @@ const PreviewPanel: React.FC = ({ className = '' }) => { const cv = useCvStore(state => state.cv); const templateId = useCvStore(state => state.cv.templateId); const [mode, setMode] = useState<'inline' | 'printable'>('inline'); - const printableHtml = useMemo(() => buildPrintableHtml(cv), [cv]); + const printableHtml = useMemo(() => buildPrintableHtml(cv, templateId), [cv, templateId]); return (
@@ -21,7 +21,7 @@ const PreviewPanel: React.FC = ({ className = '' }) => {

Preview

- Template: {templateId === 'ats' ? 'ATS-Friendly' : templateId} + Template: {templatesMap[templateId]?.name || templateId}
-
+
+ {mode === 'inline' && ( <> - {/* Render the appropriate template based on templateId */} - {templateId === 'ats' && } - {templateId === 'classic' && } - {/* Add more template options here as they are implemented */} + {/* Render component via registry */} + {(() => { + const Comp = templatesMap[templateId]?.component; + return Comp ? : null; + })()} )} diff --git a/cv-engine/src/components/TemplateGallery.tsx b/cv-engine/src/components/TemplateGallery.tsx new file mode 100644 index 0000000..a22f738 --- /dev/null +++ b/cv-engine/src/components/TemplateGallery.tsx @@ -0,0 +1,58 @@ +import React, { useMemo, useState } from 'react'; +import { templatesRegistry } from '../templates/registry'; +import { useCvStore } from '../store/cvStore'; + +interface TemplateGalleryProps { + className?: string; +} + +const TemplateGallery: React.FC = ({ className = '' }) => { + const templateId = useCvStore(state => state.cv.templateId); + const updateTemplateId = useCvStore(state => state.updateTemplateId); + const [filter, setFilter] = useState<'All' | 'ATS' | 'Visual'>('All'); + + const filtered = useMemo(() => { + return templatesRegistry.filter(t => filter === 'All' ? true : t.category === filter); + }, [filter]); + + return ( +
+
+

Templates

+
+ {(['All', 'ATS', 'Visual'] as const).map(k => ( + + ))} +
+
+ +
+ {filtered.map(t => ( + + ))} +
+
+ ); +}; + +export default TemplateGallery; \ No newline at end of file diff --git a/cv-engine/src/editors/PersonalEditor.tsx b/cv-engine/src/editors/PersonalEditor.tsx index d46cd52..91b9c52 100644 --- a/cv-engine/src/editors/PersonalEditor.tsx +++ b/cv-engine/src/editors/PersonalEditor.tsx @@ -40,6 +40,14 @@ const PersonalEditor: React.FC = () => { } else if (!emailRegex.test(value)) { setErrors(prev => ({ ...prev, email: 'Invalid email format' })); } + } else if (name === 'photoUrl' && value.trim()) { + // Basic URL normalization and validation + const normalized = normalizeUrl(value.trim()); + if (!/^https?:\/\//i.test(normalized)) { + setErrors(prev => ({ ...prev, photoUrl: 'URL must start with http:// or https://' })); + } else { + setFormData(prev => ({ ...prev, photoUrl: normalized })); + } } // Update store on blur if no errors @@ -199,22 +207,43 @@ const PersonalEditor: React.FC = () => { )}
- {/* Phone */} -
- - -
+ {/* Phone */} +
+ +
+ + {/* Photo URL */} +
+ + + {errors.photoUrl && ( +

{errors.photoUrl}

+ )} +
+
{/* Address Fields */}
diff --git a/cv-engine/src/schema/cvSchema.ts b/cv-engine/src/schema/cvSchema.ts index 2646373..75b7167 100644 --- a/cv-engine/src/schema/cvSchema.ts +++ b/cv-engine/src/schema/cvSchema.ts @@ -26,6 +26,7 @@ export const CvSchema = z.object({ lastName: z.string().min(1, "Last name is required"), email: z.string().email("Invalid email address"), phone: z.string().optional(), + photoUrl: z.string().optional().default(""), street: z.string().optional(), city: z.string().optional(), country: z.string().optional(), @@ -99,6 +100,7 @@ export const createEmptyCV = (): CV => ({ lastName: "", email: "", phone: "", + photoUrl: "", street: "", city: "", country: "", diff --git a/cv-engine/src/templates/MinimalTemplate.tsx b/cv-engine/src/templates/MinimalTemplate.tsx new file mode 100644 index 0000000..f8731e9 --- /dev/null +++ b/cv-engine/src/templates/MinimalTemplate.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import type { CV } from '../schema/cvSchema'; + +interface MinimalTemplateProps { cv: CV; className?: string; } + +// Minimal clean template with generous whitespace and serif headings +const MinimalTemplate: React.FC = ({ cv, className = '' }) => { + const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv; + + return ( +
+
+

{(personal?.firstName || '')} {(personal?.lastName || '')}

+
+ {personal?.email && {personal.email}} + {personal?.phone && {personal.phone}} + {(personal?.city || personal?.country) && ( + + {personal.city}{personal.city && personal.country ? ', ' : ''}{personal.country} + + )} +
+
+ + {summary && ( +
+

Summary

+
+
+ )} + + {work.length > 0 && ( +
+

Experience

+
+ {work.map((job) => ( +
+
+
+

{job.title}

+
{job.company}
+
+
+ {job.startDate} - {job.endDate || 'Present'} + {job.location &&
{job.location}
} +
+
+ {job.bullets && job.bullets.length > 0 && ( +
    + {job.bullets.map((b, i) => ( +
  • {b}
  • + ))} +
+ )} +
+ ))} +
+
+ )} + + {education.length > 0 && ( +
+

Education

+
+ {education.map((ed) => ( +
+
+
+

{ed.degree}

+
{ed.school}
+
+
{ed.startDate}{ed.endDate && <> - {ed.endDate}}
+
+
+ ))} +
+
+ )} + + {(skills.length > 0 || languages.length > 0 || certifications.length > 0) && ( +
+ {skills.length > 0 && ( +
+

Skills

+
+ {skills.map((s) => ( + + {s.name}{s.level ? ` — ${s.level}` : ''} + + ))} +
+
+ )} + {languages.length > 0 && ( +
+

Languages

+
+ {languages.map((l) => ( + + {l.name}{l.level ? ` — ${l.level}` : ''} + + ))} +
+
+ )} + {certifications.length > 0 && ( +
+

Certifications

+
    + {certifications.map((c) => ( +
  • {c.name}{c.issuer ? ` - ${c.issuer}` : ''}{c.date ? ` (${c.date})` : ''}
  • + ))} +
+
+ )} +
+ )} +
+ ); +}; + +export default MinimalTemplate; \ No newline at end of file diff --git a/cv-engine/src/templates/ModernTemplate.tsx b/cv-engine/src/templates/ModernTemplate.tsx new file mode 100644 index 0000000..b0bba44 --- /dev/null +++ b/cv-engine/src/templates/ModernTemplate.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import type { CV } from '../schema/cvSchema'; + +interface ModernTemplateProps { cv: CV; className?: string; } + +// Modern visual template with accent header and two-column sections +const ModernTemplate: React.FC = ({ cv, className = '' }) => { + const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv; + + return ( +
+
+ {personal?.photoUrl && ( + {`${personal.firstName + )} +

+ {(personal?.firstName || '')} {(personal?.lastName || '')} +

+
+ {personal.email && {personal.email}} + {personal.phone && {personal.phone}} + {(personal.city || personal.country) && ( + + {personal.city}{personal.city && personal.country ? ', ' : ''}{personal.country} + + )} +
+
+ +
+
+ {summary && ( +
+

Professional Summary

+
+
+ )} + + {work.length > 0 && ( +
+

Work Experience

+
+ {work.map((job) => ( +
+
+
+

{job.title}

+
{job.company}
+
+
+ {job.startDate} - {job.endDate || 'Present'} + {job.location &&
{job.location}
} +
+
+ {job.bullets && job.bullets.length > 0 && ( +
    + {job.bullets.map((b, i) => ( +
  • {b}
  • + ))} +
+ )} +
+ ))} +
+
+ )} + + {education.length > 0 && ( +
+

Education

+
+ {education.map((ed) => ( +
+
+
+

{ed.degree}

+
{ed.school}
+
+
{ed.startDate}{ed.endDate && <> - {ed.endDate}}
+
+
+ ))} +
+
+ )} +
+ +
+ {skills.length > 0 && ( +
+

Skills

+
+ {skills.map((s) => ( + + {s.name}{s.level ? ` — ${s.level}` : ''} + + ))} +
+
+ )} + + {languages.length > 0 && ( +
+

Languages

+
+ {languages.map((l) => ( + + {l.name}{l.level ? ` — ${l.level}` : ''} + + ))} +
+
+ )} + + {certifications.length > 0 && ( +
+

Certifications

+
    + {certifications.map((c) => ( +
  • {c.name}{c.issuer ? ` - ${c.issuer}` : ''}{c.date ? ` (${c.date})` : ''}
  • + ))} +
+
+ )} +
+
+
+ ); +}; + +export default ModernTemplate; \ No newline at end of file diff --git a/cv-engine/src/templates/registry.ts b/cv-engine/src/templates/registry.ts new file mode 100644 index 0000000..1b7acab --- /dev/null +++ b/cv-engine/src/templates/registry.ts @@ -0,0 +1,29 @@ +import ATSTemplate from './ATSTemplate'; +import ClassicTemplate from './ClassicTemplate'; +import ModernTemplate from './ModernTemplate'; +import MinimalTemplate from './MinimalTemplate'; +import atsThumb from '../assets/templates/ats.svg'; +import classicThumb from '../assets/templates/classic.svg'; +import modernThumb from '../assets/templates/modern.svg'; +import minimalThumb from '../assets/templates/minimal.svg'; +import type { CV } from '../schema/cvSchema'; +import React from 'react'; + +export type TemplateCategory = 'ATS' | 'Visual'; + +export interface TemplateMeta { + id: string; + name: string; + category: TemplateCategory; + thumbnail: string; // path to asset + component: React.FC<{ cv: CV }>; +} + +export const templatesRegistry: TemplateMeta[] = [ + { id: 'ats', name: 'ATS-Friendly', category: 'ATS', thumbnail: atsThumb, component: ATSTemplate }, + { id: 'classic', name: 'Classic', category: 'Visual', thumbnail: classicThumb, component: ClassicTemplate }, + { id: 'modern', name: 'Modern', category: 'Visual', thumbnail: modernThumb, component: ModernTemplate }, + { id: 'minimal', name: 'Minimal', category: 'Visual', thumbnail: minimalThumb, component: MinimalTemplate }, +]; + +export const templatesMap = Object.fromEntries(templatesRegistry.map(t => [t.id, t])); \ No newline at end of file diff --git a/cv-engine/src/utils/printable.ts b/cv-engine/src/utils/printable.ts index fcd87c0..92083c5 100644 --- a/cv-engine/src/utils/printable.ts +++ b/cv-engine/src/utils/printable.ts @@ -3,10 +3,10 @@ import { sanitizeHtml } from '../schema/cvSchema'; import { buildPrintableHtml as sharedBuild } from '../../../shared-printable/buildPrintableHtml.js'; // Thin wrapper to ensure client-side sanitization and shared rendering parity -export const buildPrintableHtml = (cv: CV): string => { +export const buildPrintableHtml = (cv: CV, templateId?: string): string => { const cleaned: CV = { ...cv, summary: cv.summary ? sanitizeHtml(cv.summary) : '' }; - return sharedBuild(cleaned); + return sharedBuild(cleaned, templateId); }; \ No newline at end of file diff --git a/cv-export-server/server.js b/cv-export-server/server.js index 2adafb8..f505550 100644 --- a/cv-export-server/server.js +++ b/cv-export-server/server.js @@ -219,7 +219,7 @@ app.post('/export/pdf', async (req, res) => { try { const current = jobs.get(jobId); if (current?.status === 'canceled') return; // honor cancel - const html = sharedBuild(sanitizedCv); + const html = sharedBuild(sanitizedCv, templateId); const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'networkidle0' }); @@ -235,7 +235,7 @@ app.post('/export/pdf', async (req, res) => { } // Synchronous path - const html = sharedBuild(sanitizedCv); + const html = sharedBuild(sanitizedCv, templateId); const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'networkidle0' }); diff --git a/shared-printable/buildPrintableHtml.d.ts b/shared-printable/buildPrintableHtml.d.ts index 9fbafc9..0cb4801 100644 --- a/shared-printable/buildPrintableHtml.d.ts +++ b/shared-printable/buildPrintableHtml.d.ts @@ -1,2 +1,2 @@ export type CV = import('../cv-engine/src/schema/cvSchema').CV; -export function buildPrintableHtml(cv: CV): string; \ No newline at end of file +export function buildPrintableHtml(cv: CV, templateId?: string): string; \ No newline at end of file diff --git a/shared-printable/buildPrintableHtml.js b/shared-printable/buildPrintableHtml.js index 8775eac..d1c8d2f 100644 --- a/shared-printable/buildPrintableHtml.js +++ b/shared-printable/buildPrintableHtml.js @@ -11,26 +11,63 @@ function escapeText(str) { .replace(/'/g, '''); } -export function buildPrintableHtml(cv) { - const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [] } = cv || {}; +export function buildPrintableHtml(cv, templateId) { + const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [], templateId: cvTemplateId } = cv || {}; + const tid = templateId || cvTemplateId || 'ats'; + + const baseStyles = ` + @page { size: A4; margin: 20mm; } + body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; color: #1f2937; } + .page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; } + h1 { font-size: 24px; margin: 0 0 6px 0; } + h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; } + h3 { font-size: 14px; margin: 0; } + .header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; } + .meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; } + .section { margin-bottom: 16px; } + .job, .edu { margin-bottom: 12px; } + .row { display: flex; justify-content: space-between; align-items: flex-start; } + .small { font-size: 12px; color: #6b7280; } + ul { padding-left: 20px; } + li { margin: 4px 0; } + .chips { display: flex; flex-wrap: wrap; gap: 6px; } + .chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; } + `; + + const classicStyles = ` + @page { size: A4; margin: 22mm; } + body { font-family: Georgia, Cambria, 'Times New Roman', Times, serif; color: #111827; } + h1 { font-size: 26px; } + h2 { font-size: 16px; margin: 18px 0 6px 0; padding-bottom: 0; border-bottom: none; color: #374151; letter-spacing: 0.2px; } + .header { border-bottom: none; padding-bottom: 0; margin-bottom: 10px; } + .chip { background: #f9fafb; color: #111827; } + `; + + const modernStyles = ` + @page { size: A4; margin: 18mm; } + body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; color: #0f172a; } + .accent { height: 6px; background: #0ea5e9; margin-bottom: 12px; } + h1 { font-weight: 700; letter-spacing: 0.3px; } + h2 { color: #0ea5e9; border-color: #bae6fd; } + .header { border-left: 6px solid #0ea5e9; padding-left: 12px; } + .chip { background: #e0f2fe; color: #0c4a6e; } + `; + + const minimalStyles = ` + @page { size: A4; margin: 25mm; } + body { font-family: 'Source Serif Pro', Georgia, Cambria, 'Times New Roman', Times, serif; color: #1f2937; } + h1 { font-size: 24px; font-weight: 600; } + h2 { font-size: 15px; margin: 22px 0 6px 0; border-bottom: none; color: #374151; } + .header { border-bottom: none; margin-bottom: 8px; } + .section { margin-bottom: 22px; } + .chip { background: #f3f4f6; color: #374151; } + `; + + const stylesVariant = tid === 'classic' ? classicStyles : tid === 'modern' ? modernStyles : tid === 'minimal' ? minimalStyles : ''; const styles = ` `; @@ -38,14 +75,19 @@ export function buildPrintableHtml(cv) { .map(l => `${escapeText(l.label || l.url)}`) .join(' · '); + const safePhotoUrl = typeof personal.photoUrl === 'string' && /^(https?:\/\/)/i.test(personal.photoUrl) ? personal.photoUrl : ''; + const headerPhoto = tid === 'modern' && safePhotoUrl ? `${escapeText((personal.firstName || '') + ' ' + (personal.lastName || ''))} photo` : ''; const headerHtml = ` -
-

${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}

-
- ${personal.email ? `${escapeText(personal.email)}` : ''} - ${personal.phone ? `${escapeText(personal.phone)}` : ''} - ${(personal.city || personal.country) ? `${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}` : ''} - ${headerLinks ? `${headerLinks}` : ''} +
+ ${headerPhoto} +
+

${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}

+
+ ${personal.email ? `${escapeText(personal.email)}` : ''} + ${personal.phone ? `${escapeText(personal.phone)}` : ''} + ${(personal.city || personal.country) ? `${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}` : ''} + ${headerLinks ? `${headerLinks}` : ''} +
`; @@ -129,6 +171,7 @@ export function buildPrintableHtml(cv) {
` : ''; + const accentTop = tid === 'modern' ? '
' : ''; return ` @@ -139,6 +182,7 @@ export function buildPrintableHtml(cv) {
+ ${accentTop} ${headerHtml} ${summarySection} ${workSection}