Compare commits

...

10 Commits

Author SHA1 Message Date
a836a7a1d6 docs: update readme with comprehensive developer onboarding guide
Expand README to include detailed developer onboarding information covering:
- Quick start instructions
- Repository layout and core concepts
- Data model and state management
- App flow and component architecture
- Template system and theming
- Printable HTML and export process
- Contribution guidelines and troubleshooting
2025-10-15 09:56:58 +01:00
3a9591fb48 feat(templates): add color theme support and template selection step
Implement color theme system with 9 color options and default theme
Add template selection as first step in CV creation flow
Update all templates to support dynamic color theming
Create ThemeProvider component to apply theme styles
Add template thumbnails with color theme variations
Extend CV schema with colorTheme field
Update store to handle template selection and color theme state
2025-10-14 22:39:36 +01:00
81712044c2 feat(templates): add timeline template with sidebar layout
- Implement new timeline template with left sidebar and vertical timeline design
- Add support for headline and reference lines in personal editor
- Update printable HTML builder to support timeline template styling
- Include timeline template thumbnail and registry entry
2025-10-12 19:51:28 +01:00
2847cef81b feat(templates): add html sanitization for summary sections
Add supportsPhoto flag to template registry and conditionally render photo URL field
2025-10-11 17:42:05 +01:00
d35700bc10 feat(templates): add multiple CV templates with photo support and template selection
- Add 4 new CV templates (ATS-Friendly, Classic, Modern, Minimal) with thumbnails
- Implement template registry and gallery component for template selection
- Add photoUrl field to personal info with URL validation
- Update buildPrintableHtml to support template-specific styling
- Modify ExportControls to use template registry
- Add template preview thumbnails in SVG format
2025-10-06 08:29:33 +01:00
44fdc1ce52 feat(skills): add skill reordering functionality
Implement drag-and-drop alternative for skill reordering with up/down buttons. Also improve skill level type safety and filename generation by including date.

refactor(export): update PDF filename format to include date
2025-10-06 01:26:19 +01:00
0ebf5fe3de feat(cv-export): add PDF export functionality with async job support
Implement PDF export feature with both synchronous and asynchronous modes. Includes:
- New cv-export-server service using Puppeteer
- Shared printable HTML builder module
- ExportControls React component with job status tracking
- Classic template for PDF output
- API endpoints for job management

The system supports cancelable async jobs with polling and error handling. Both client and server share the same HTML rendering logic via the shared-printable module.
2025-10-06 01:10:02 +01:00
adeb8473b7 feat(autosave): add local autosave functionality with status indicator
implement autosave hook that persists CV data to localStorage with debounce
add status component to display save state in header
extend store to track save status and errors
2025-10-06 00:15:36 +01:00
3468cf7a43 feat(editor): add placeholder and JSON support to summary editor
- Add @tiptap/extension-placeholder dependency
- Implement placeholder text in summary editor
- Add JSON persistence for summary content
- Enhance HTML sanitization to support safe links
2025-10-05 23:56:09 +01:00
48b48f8165 feat(summary): implement summary editor with validation and preview
Add summary editor component with rich text formatting capabilities
Add validation for summary section in cv store
Implement printable preview mode in preview panel
Replace placeholder with actual summary editor in App component
Update HTML sanitization to use DOMPurify with allowed tags
2025-10-05 23:45:56 +01:00
46 changed files with 5787 additions and 137 deletions

155
README.md
View File

@ -1,4 +1,150 @@
# CV Editor Engine — Development Guide
# CV Engine — Developer Onboarding Guide
This guide helps new developers ramp up quickly on the CV Engine. It explains the architecture, how components work, data flow, theming, templates, and how to run and extend the project.
## Quick Start
- Prereqs: Node.js 18+ and npm.
- Client setup:
- `cd cv-engine`
- `npm install`
- `npm run dev`
- Open `http://localhost:5173/`.
- (Optional) Export server for PDF:
- `cd cv-export-server`
- `npm install`
- `npm run dev`
- Endpoint: `POST /export/pdf` (consumes CV JSON, returns PDF).
## Repository Layout
- `cv-engine/` — React + Vite app: editors, templates, preview, theming, thumbnails.
- `shared-printable/` — Shared HTML builder used by client and server.
- `cv-export-server/` — Express service that renders PDFs with Puppeteer.
## Core Concepts
- Single source of truth: CV is a JSON model validated by Zod (`cv-engine/src/schema/cvSchema.ts`).
- Inline preview renders React templates; printable preview uses a shared HTML builder for A4 output.
- Theming is applied via CSS variables so templates and thumbnails react to color selection.
## Data Model & State
- `src/schema/cvSchema.ts`
- Fields: `personal`, `summary`, `work`, `education`, `skills`, `languages`, `certifications`, `templateId`, `colorTheme`.
- Validation: bullets ≤160 chars, summary ≤600 chars; required fields for jobs/education.
- Helpers: `sanitizeHtml`, `escapeText`, `createEmptyCV`.
- `src/store/cvStore.ts`
- Holds CV data and UI state (`activeStep`, `isDirty`, save status).
- Actions: `updatePersonal`, `addWorkItem`, `updateWorkItem`, reorder functions, section validators, `updateTemplateId`, `updateColorTheme`.
- Selectors (via hooks): used across editors and preview to read/write state.
## App Flow & Top-Level Components
- `src/App.tsx`
- Orchestrates the stepper and current editor panel (template, personal, work, education, skills, summary).
- Right panel shows live preview (`PreviewPanel`). Autosaves using `useLocalAutosave`.
- `src/components/Stepper.tsx`
- Displays steps and controls navigation.
- Validates the current section before moving forward.
- `src/components/TemplateGallery.tsx`
- Shows a grid of template thumbnails with filters (`ATS` vs `Visual`).
- Clicking a card sets `templateId` in state.
- `src/components/PreviewPanel.tsx`
- Toggle between `inline` and `printable` modes.
- Inline: renders the selected React template via `templatesMap[templateId]` inside `ThemeProvider`.
- Printable: builds full A4 HTML with `buildPrintableHtml(cv, templateId)` and loads it in an iframe.
- Also includes `TemplateGallery` and export controls.
- `src/components/ThemeProvider.tsx`
- Applies CSS variables from the selected `ColorTheme` (`getThemeCSS`) to make templates/theme-aware.
- `src/components/ExportControls.tsx`
- Buttons to kick off export to the server, track job status, and download the result.
## Editors (data entry)
- PersonalEditor (path: `src/editors/PersonalEditor.tsx`)
- Collects name, contact details, location, links; updates `cv.personal`.
- `src/editors/WorkEditor.tsx`
- CRUD jobs, reorder, and manage bullets with length guidance.
- Validates required fields (`title`, `company`, `startDate`) and prevents more than 6 bullets.
- `src/editors/EducationEditor.tsx`
- CRUD education entries; degree, school, dates, optional notes; reorder.
- `src/editors/SkillsEditor.tsx`
- Add skills with optional level; reorder skills; chips-style display.
- `src/editors/SummaryEditor.tsx`
- Rich text editor (Tiptap) with bold/italic and lists, character count, and sanitization.
## Templates (inline preview)
- `src/templates/ATSTemplate.tsx`
- ATS-friendly, serif typography, underlined section headings.
- Sections: Header (centered), Professional Summary, Professional Experience (company, title, date range, bullets), Education, Skills, Languages.
- Uses helpers: `escapeText`, `sanitizeHtml`, `formatDate` and CSS variables (`var(--theme-*)`).
- `src/templates/ClassicTemplate.tsx`
- Clean, traditional layout; accent color on headings; chip-style skills.
- `src/templates/ModernTemplate.tsx`
- Visual layout with colored header and two-column sections; optional photo.
- `src/templates/MinimalTemplate.tsx`
- Bare, whitespace-forward layout; serif headings; simple chips for skills and languages.
- `src/templates/TimelineTemplate.tsx`
- Visual timeline with a left sidebar for contact/skills and right-column timeline content.
- `src/templates/registry.ts`
- Declares `templatesRegistry` and `templatesMap` (id → component, name, category, thumbnail).
- Thumbnails can be static SVGs in `src/assets/templates` or dynamically generated.
## Thumbnails & Theming
- `src/utils/thumbnailGenerator.ts`
- Contains `baseSVGs` with SVG blueprints per template using placeholders like `{{primary}}`, `{{text}}`, `{{background}}`.
- Generates data URLs by replacing placeholders with the active theme palette.
- ATS thumbnail is sectioned and theme-aware to reflect the templates look.
## Printable HTML & Export
- Client wrapper: `src/utils/printable.ts`
- Sanitizes summary and calls `shared-printable/buildPrintableHtml.js` to build full A4 HTML.
- Shared builder: `shared-printable/buildPrintableHtml.js`
- Returns a complete HTML document with print-safe styles; supports multiple templates (ATS, Timeline).
- Server: `cv-export-server/server.js`
- `POST /export/pdf` consumes CV JSON, builds HTML, renders PDF via Puppeteer, and streams it back.
- You can later move this to a worker or serverless with proper Chromium setup.
## Theming (colors)
- `src/types/colors.ts` (referenced via imports in the app)
- Defines `ColorTheme` and named palettes.
- `getThemeCSS(theme)` returns CSS variables (`--theme-primary`, `--theme-secondary`, `--theme-text`, etc.) applied by `ThemeProvider`.
- Templates and thumbnails consume these via `var(--theme-*)` or placeholder substitution.
## Typical Development Tasks
- Add a new template
- Create `src/templates/MyTemplate.tsx` (React component accepting `{ cv }`).
- Add a thumbnail (static SVG under `src/assets/templates` or a `baseSVGs.myTemplate` entry in `thumbnailGenerator.ts`).
- Register in `src/templates/registry.ts` with id, name, category, and component.
- If printable output differs significantly, add a variant to `shared-printable/buildPrintableHtml.js` keyed by `templateId`.
- Add a new field to the CV model
- Update `cvSchema.ts` (Zod schema and default values).
- Extend relevant editors and templates to read/write/render the field.
- Update `buildPrintableHtml.js` if needed for print parity.
- Add a new color theme
- Extend palettes in `types/colors.ts`.
- The rest of the app picks up the theme via `ThemeProvider` and SVG thumbnail generator.
## Commands
- Client: `npm run dev`, `npm run build`, `npm run preview`, `npm run lint` (inside `cv-engine`).
- Server: `npm run dev`, `npm run start` (inside `cv-export-server`).
## Testing & Quality
- Unit/integration: suggested in docs (Vitest/Jest, React Testing Library).
- Visual regression: consider Percy/Chromatic for templates and printable HTML screenshots.
- E2E: Playwright covering editor flow, preview, export.
- Linting: ESLint config in `cv-engine/eslint.config.js` with TS + React rules.
## Troubleshooting
- Dev server not reachable: ensure `npm run dev` is running and check for port conflicts.
- Summary HTML issues: verify `sanitizeHtml` usage and allowed tags.
- PDF export errors: confirm the server is running; check console logs; verify Puppeteer can launch (server or CI environment may need Chromium flags).
- Template thumbnails: if colors dont change, verify placeholders in `thumbnailGenerator.ts` and theme CSS variables are applied.
## Contribution Guidelines
- Keep changes scoped; follow existing code style.
- Maintain inline ↔ printable parity when modifying templates.
- Sanitize and validate user-provided content; avoid introducing unsafe HTML.
- Prefer small, focused PRs with clear rationale.
---
## Appendix: Original Development Guide
The original, more detailed plan and background documentation follows below for deeper context.
This guide turns the CV editor engine plan into concrete, incremental tasks you can implement. It defines scope, architecture, data model, workflows, templates, preview/export, validation, accessibility, security, performance, testing, and milestones with acceptance criteria.
@ -234,6 +380,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.

View File

@ -9,9 +9,11 @@
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.90.2",
"@tiptap/extension-placeholder": "^3.6.5",
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"dompurify": "^3.2.7",
"postcss": "^8.5.6",
"react": "^19.1.1",
@ -2022,6 +2024,19 @@
"@tiptap/core": "^3.6.5"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.6.5.tgz",
"integrity": "sha512-9CLixogEb/4UkEyuDr4JdOlLvphcOVfZMdNMKmUVQdqo4MuZCdTDyK5ypfTPQJl8aUo0oCiEhqE0bQerYlueJQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.6.5"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.6.5.tgz",
@ -2644,6 +2659,12 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@ -2681,6 +2702,17 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2754,6 +2786,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -2831,6 +2876,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2897,6 +2954,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -2916,6 +2982,20 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.230",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.230.tgz",
@ -2948,6 +3028,51 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
@ -3313,6 +3438,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -3341,6 +3502,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -3351,6 +3521,43 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -3377,6 +3584,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -3401,6 +3620,45 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3893,6 +4151,15 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@ -3923,6 +4190,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -4353,6 +4641,12 @@
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -11,9 +11,11 @@
},
"dependencies": {
"@tanstack/react-query": "^5.90.2",
"@tiptap/extension-placeholder": "^3.6.5",
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"dompurify": "^3.2.7",
"postcss": "^8.5.6",
"react": "^19.1.1",

View File

@ -1,18 +1,25 @@
import React from 'react';
import Stepper from './components/Stepper';
import TemplateSelectionEditor from './editors/TemplateSelectionEditor';
import PersonalEditor from './editors/PersonalEditor';
import WorkEditor from './editors/WorkEditor';
import EducationEditor from './editors/EducationEditor';
import SkillsEditor from './editors/SkillsEditor';
import SummaryEditor from './editors/SummaryEditor';
import PreviewPanel from './components/PreviewPanel';
import { useActiveStep } from './store/cvStore';
import { useLocalAutosave } from './hooks/useLocalAutosave';
import AutosaveStatus from './components/AutosaveStatus';
const App: React.FC = () => {
const activeStep = useActiveStep();
useLocalAutosave('cv-engine:draft', 800);
// Render the appropriate editor based on the active step
const renderEditor = () => {
switch (activeStep) {
case 'template':
return <TemplateSelectionEditor />;
case 'personal':
return <PersonalEditor />;
case 'work':
@ -22,11 +29,11 @@ const App: React.FC = () => {
case 'skills':
return <SkillsEditor />;
case 'summary':
return <div className="p-6 bg-white rounded-lg shadow-sm">Summary editor will be implemented in M3</div>;
return <SummaryEditor />;
case 'finalize':
return <div className="p-6 bg-white rounded-lg shadow-sm">Finalize step will be implemented in M5</div>;
default:
return <PersonalEditor />;
return <TemplateSelectionEditor />;
}
};
@ -34,13 +41,23 @@ const App: React.FC = () => {
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">CV Engine</h1>
<AutosaveStatus />
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
<Stepper className="mb-8" />
{activeStep === 'template' ? (
// Full-screen template selection
<div className="w-full">
{renderEditor()}
</div>
) : (
// Two-column layout for other steps
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Editor Panel */}
<div className="lg:col-span-1">
@ -52,6 +69,7 @@ const App: React.FC = () => {
<PreviewPanel className="h-full" />
</div>
</div>
)}
</main>
<footer className="bg-white mt-12 py-6 border-t">

View File

@ -0,0 +1,81 @@
import axios from 'axios';
import type { CV } from '../schema/cvSchema';
export interface ExportResult { filename: string; }
export interface JobResponse { jobId: string; }
export interface JobStatus { status: 'queued' | 'done' | 'error' | 'canceled'; filename?: string; error?: string; }
// Synchronous export (no job)
export async function exportPdf(cv: CV, endpoint = 'http://localhost:4000/export/pdf', templateId?: string): Promise<ExportResult> {
const response = await axios.post(endpoint, { ...cv, templateId }, { responseType: 'blob' });
const contentDisposition = response.headers['content-disposition'] || '';
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
const filename = match ? match[1] : (() => {
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, '0');
const d = String(today.getDate()).padStart(2, '0');
const dateStr = `${y}${m}${d}`;
const safeLast = (cv.personal.lastName || '').trim() || 'cv';
return `cv-${safeLast}-${dateStr}.pdf`;
})();
const blob = new Blob([response.data], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
return { filename };
}
// Async export: request job, poll status with backoff, then download
export async function requestExportJob(cv: CV, endpoint = 'http://localhost:4000/export/pdf', templateId?: string): Promise<string> {
const response = await axios.post<JobResponse>(`${endpoint}?async=1`, { ...cv, templateId });
return response.data.jobId;
}
export async function pollJobStatus(jobId: string, baseUrl = 'http://localhost:4000', initialDelayMs = 500, maxDelayMs = 5000, maxAttempts = 10): Promise<JobStatus> {
let attempt = 0;
let delay = initialDelayMs;
while (attempt < maxAttempts) {
const { data } = await axios.get<JobStatus>(`${baseUrl}/export/status/${jobId}`);
if (data.status === 'done' || data.status === 'error' || data.status === 'canceled') return data;
await new Promise(r => setTimeout(r, delay));
delay = Math.min(Math.round(delay * 1.6), maxDelayMs);
attempt += 1;
}
return { status: 'error', error: 'Timeout waiting for export' };
}
export async function downloadJob(jobId: string, baseUrl = 'http://localhost:4000'): Promise<ExportResult> {
const response = await axios.get(`${baseUrl}/export/download/${jobId}`, { responseType: 'blob' });
const contentDisposition = response.headers['content-disposition'] || '';
const match = /filename="?([^";]+)"?/i.exec(contentDisposition);
const filename = match ? match[1] : (() => {
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, '0');
const d = String(today.getDate()).padStart(2, '0');
const dateStr = `${y}${m}${d}`;
return `cv-cv-${dateStr}.pdf`;
})();
const blob = new Blob([response.data], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
return { filename };
}
export async function cancelJob(jobId: string, baseUrl = 'http://localhost:4000'): Promise<void> {
await axios.post(`${baseUrl}/export/cancel/${jobId}`);
}

View File

@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="100" viewBox="0 0 160 100">
<!-- Background frame -->
<rect x="1" y="1" width="158" height="98" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<!-- Header: Name centered -->
<text x="80" y="15" font-size="8" font-weight="bold" fill="#000" font-family="Times New Roman, serif" text-anchor="middle">FIRST LAST</text>
<text x="80" y="22" font-size="3" fill="#000" font-family="Times New Roman, serif" text-anchor="middle">Bay Area, California • +1-234-456-789 • professionalemail@resumeworded.com • linkedin.com/in/username</text>
<!-- Section: PROFESSIONAL EXPERIENCE -->
<text x="12" y="32" font-size="4" font-weight="bold" fill="#000" font-family="Times New Roman, serif">PROFESSIONAL EXPERIENCE</text>
<line x1="12" y1="33" x2="148" y2="33" stroke="#000" stroke-width="0.6"/>
<!-- Job 1 -->
<text x="12" y="39" font-size="3.5" font-weight="bold" fill="#000" font-family="Times New Roman, serif">Resume Worded</text>
<text x="52" y="39" font-size="3.5" fill="#000" font-family="Times New Roman, serif">, New York, NY</text>
<text x="148" y="39" font-size="3.5" fill="#000" font-family="Times New Roman, serif" text-anchor="end">Jun 2018 Present</text>
<text x="12" y="43" font-size="3.5" font-style="italic" fill="#000" font-family="Times New Roman, serif">Human Resources Manager</text>
<circle cx="14" cy="46" r="0.45" fill="#000"/>
<text x="17" y="47" font-size="3" fill="#000" font-family="Times New Roman, serif">Structured and implemented programs and policies; reduced recruiting costs by 70%.</text>
<circle cx="14" cy="50" r="0.45" fill="#000"/>
<text x="17" y="51" font-size="3" fill="#000" font-family="Times New Roman, serif">Led HR strategy for 150+ franchises; generated over $40M in revenue.</text>
<circle cx="14" cy="54" r="0.45" fill="#000"/>
<text x="17" y="55" font-size="3" fill="#000" font-family="Times New Roman, serif">Played integral role in company reorganization and franchise oversight.</text>
<!-- Job 2 -->
<text x="12" y="61" font-size="3.5" font-weight="bold" fill="#000" font-family="Times New Roman, serif">Second Company</text>
<text x="58" y="61" font-size="3.5" fill="#000" font-family="Times New Roman, serif">, New York, NY</text>
<text x="148" y="61" font-size="3.5" fill="#000" font-family="Times New Roman, serif" text-anchor="end">Jan 2015 May 2018</text>
<text x="12" y="65" font-size="3.5" font-style="italic" fill="#000" font-family="Times New Roman, serif">Human Resources Manager</text>
<circle cx="14" cy="68" r="0.45" fill="#000"/>
<text x="17" y="69" font-size="3" fill="#000" font-family="Times New Roman, serif">Implemented employee referral program; reduced cost per hire by 35%.</text>
<circle cx="14" cy="72" r="0.45" fill="#000"/>
<text x="17" y="73" font-size="3" fill="#000" font-family="Times New Roman, serif">Administered benefits for 350+ union and non-union employees.</text>
<!-- Section: EDUCATION -->
<text x="12" y="80" font-size="4" font-weight="bold" fill="#000" font-family="Times New Roman, serif">EDUCATION</text>
<line x1="12" y1="81" x2="148" y2="81" stroke="#000" stroke-width="0.6"/>
<text x="12" y="86" font-size="3.5" font-weight="bold" fill="#000" font-family="Times New Roman, serif">Resume Worded University</text>
<text x="90" y="86" font-size="3.5" fill="#000" font-family="Times New Roman, serif">, San Francisco, CA</text>
<text x="148" y="86" font-size="3.5" fill="#000" font-family="Times New Roman, serif" text-anchor="end">May 2010</text>
<text x="12" y="90" font-size="3.5" font-style="italic" fill="#000" font-family="Times New Roman, serif">Bachelor of Science in Human Resource Management</text>
<!-- Section: SKILLS -->
<text x="12" y="95" font-size="4" font-weight="bold" fill="#000" font-family="Times New Roman, serif">SKILLS</text>
<line x1="12" y1="96" x2="148" y2="96" stroke="#000" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96">
<rect x="1" y="1" width="158" height="94" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<rect x="12" y="16" width="90" height="8" fill="#cbd5e1"/>
<line x1="12" y1="28" x2="148" y2="28" stroke="#e5e7eb"/>
<rect x="12" y="36" width="136" height="8" fill="#e5e7eb"/>
<rect x="12" y="48" width="136" height="8" fill="#e5e7eb"/>
<rect x="12" y="60" width="120" height="8" fill="#e5e7eb"/>
<rect x="12" y="72" width="104" height="8" fill="#e5e7eb"/>
<text x="12" y="12" font-size="10" fill="#6b7280" font-family="Georgia, Times">Classic</text>
</svg>

After

Width:  |  Height:  |  Size: 642 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96">
<rect x="1" y="1" width="158" height="94" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<rect x="12" y="16" width="90" height="8" fill="#e5e7eb"/>
<rect x="12" y="32" width="136" height="6" fill="#f3f4f6"/>
<rect x="12" y="42" width="136" height="6" fill="#f3f4f6"/>
<rect x="12" y="52" width="120" height="6" fill="#f3f4f6"/>
<rect x="12" y="62" width="104" height="6" fill="#f3f4f6"/>
<text x="12" y="12" font-size="10" fill="#374151" font-family="Source Serif Pro, Georgia">Minimal</text>
</svg>

After

Width:  |  Height:  |  Size: 593 B

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96">
<rect x="1" y="1" width="158" height="94" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<rect x="1" y="1" width="158" height="6" fill="#0ea5e9"/>
<rect x="12" y="16" width="100" height="10" fill="#0ea5e9"/>
<rect x="12" y="32" width="136" height="8" fill="#e0f2fe"/>
<rect x="12" y="44" width="136" height="8" fill="#e0f2fe"/>
<rect x="12" y="56" width="120" height="8" fill="#e0f2fe"/>
<rect x="12" y="68" width="120" height="8" fill="#e0f2fe"/>
<rect x="12" y="80" width="104" height="8" fill="#e0f2fe"/>
<text x="12" y="12" font-size="10" fill="#0c4a6e" font-family="Inter, Arial">Modern</text>
</svg>

After

Width:  |  Height:  |  Size: 703 B

View File

@ -0,0 +1,148 @@
<svg width="400" height="520" viewBox="0 0 400 520" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.header-name { font-family: 'Arial', sans-serif; font-size: 24px; font-weight: bold; fill: #2d3748; }
.header-title { font-family: 'Arial', sans-serif; font-size: 12px; font-weight: normal; fill: #4a5568; text-transform: uppercase; letter-spacing: 1px; }
.section-title { font-family: 'Arial', sans-serif; font-size: 11px; font-weight: bold; fill: #2d3748; text-transform: uppercase; letter-spacing: 1px; }
.content-text { font-family: 'Arial', sans-serif; font-size: 9px; fill: #4a5568; }
.content-bold { font-family: 'Arial', sans-serif; font-size: 9px; font-weight: bold; fill: #2d3748; }
.content-medium { font-family: 'Arial', sans-serif; font-size: 9px; font-weight: 500; fill: #2d3748; }
.timeline-line { stroke: #cbd5e0; stroke-width: 2; fill: none; }
.timeline-dot { fill: white; stroke: #4a5568; stroke-width: 2; }
.timeline-icon-bg { fill: #2d3748; }
.timeline-icon { fill: white; }
.divider { stroke: #e2e8f0; stroke-width: 1; }
.contact-icon { fill: #4a5568; }
</style>
</defs>
<!-- Background -->
<rect width="400" height="520" fill="white"/>
<!-- Header Section -->
<text x="25" y="35" class="header-name">AHMDD SAAH</text>
<text x="25" y="52" class="header-title">MARKETING MANAGER</text>
<line x1="25" y1="65" x2="375" y2="65" class="divider"/>
<!-- Left Column (30% width = 120px) -->
<!-- Contact Section -->
<text x="25" y="90" class="section-title">CONTACT</text>
<line x1="25" y1="98" x2="135" y2="98" class="divider"/>
<!-- Phone Icon -->
<circle cx="30" cy="112" r="2" class="contact-icon"/>
<text x="40" y="116" class="content-text">+124-4236-7894</text>
<!-- Email Icon -->
<rect x="28" y="126" width="4" height="3" rx="1" class="contact-icon"/>
<text x="40" y="132" class="content-text">hello@ahmedd.saaahh.com</text>
<!-- Location Icon -->
<circle cx="30" cy="145" r="2" class="contact-icon"/>
<circle cx="30" cy="145" r="1" fill="white"/>
<text x="40" y="149" class="content-text">123fdfdgds, Any City</text>
<!-- Website Icon -->
<circle cx="30" cy="162" r="2" class="contact-icon"/>
<text x="40" y="166" class="content-text">www.ahmedd.saaahh.com</text>
<!-- Skills Section -->
<text x="25" y="195" class="section-title">SKILLS</text>
<line x1="25" y1="203" x2="135" y2="203" class="divider"/>
<text x="30" y="218" class="content-text">• Strategic Planning</text>
<text x="30" y="230" class="content-text">• Problem Solving</text>
<text x="30" y="242" class="content-text">• Crisis Management</text>
<text x="30" y="254" class="content-text">• Creative Thinking</text>
<text x="30" y="266" class="content-text">• Data Analysis</text>
<text x="30" y="278" class="content-text">• Brand Development</text>
<text x="30" y="290" class="content-text">• Negotiation</text>
<text x="30" y="302" class="content-text">• Customer Orientation</text>
<text x="30" y="314" class="content-text">• Adaptability to Change</text>
<!-- Languages Section -->
<text x="25" y="340" class="section-title">LANGUAGES</text>
<line x1="25" y1="348" x2="135" y2="348" class="divider"/>
<text x="30" y="363" class="content-text">• English (Fluent)</text>
<!-- Reference Section -->
<text x="25" y="390" class="section-title">REFERENCE</text>
<line x1="25" y1="398" x2="135" y2="398" class="divider"/>
<text x="25" y="413" class="content-bold">Estelle Darcy</text>
<text x="25" y="425" class="content-text">Wardiere Inc. / CTO</text>
<text x="25" y="437" class="content-text">Phone: +124-4236-7894</text>
<text x="25" y="449" class="content-text">Email: hello@ahmedd.saaahh.com</text>
<!-- Right Column with Timeline (70% width starts at 150px) -->
<!-- Timeline Line -->
<line x1="170" y1="90" x2="170" y2="500" class="timeline-line"/>
<!-- Profile Section -->
<circle cx="170" cy="100" r="8" class="timeline-icon-bg"/>
<circle cx="170" cy="100" r="4" class="timeline-icon"/>
<text x="190" y="90" class="section-title">PROFILE</text>
<line x1="190" y1="98" x2="375" y2="98" class="divider"/>
<text x="190" y="113" class="content-text">"Neimo enisnot est atem Aham ipsum, finifam imbo sido. Intum lumina</text>
<text x="190" y="125" class="content-text">parco regia Aham extra mola idis. Commodo Aham antegor yumo finiam</text>
<text x="190" y="137" class="content-text">carius, domus serim lacur Aham. Mauris solitude Aham in albaidis, ceras</text>
<text x="190" y="149" class="content-text">vita Aham elit exidio leo, adipisci sector elit."</text>
<!-- Work Experience Section -->
<circle cx="170" cy="180" r="8" class="timeline-icon-bg"/>
<rect x="166" y="176" width="8" height="8" class="timeline-icon"/>
<text x="190" y="170" class="section-title">WORK EXPERIENCE</text>
<line x1="190" y1="178" x2="375" y2="178" class="divider"/>
<!-- Borcelle Studio -->
<circle cx="170" cy="200" r="4" class="timeline-dot"/>
<text x="190" y="195" class="content-bold">Borcelle Studio</text>
<text x="320" y="195" class="content-text">2030 - PRESENT</text>
<text x="190" y="207" class="content-text">Marketing Manager &amp; Specialist</text>
<text x="195" y="222" class="content-text">• Formulate and implement detailed marketing strategies and initiatives</text>
<text x="197" y="234" class="content-text">that support the company's mission and objectives.</text>
<text x="195" y="246" class="content-text">• Guide, inspire, and oversee a dynamic marketing team, promoting a</text>
<text x="197" y="258" class="content-text">collaborative and performance-oriented culture.</text>
<text x="195" y="270" class="content-text">• Ensure uniformity of the brand across all marketing platforms and</text>
<text x="197" y="282" class="content-text">materials.</text>
<!-- Fauget Studio -->
<circle cx="170" cy="305" r="4" class="timeline-dot"/>
<text x="190" y="300" class="content-bold">Fauget Studio</text>
<text x="320" y="300" class="content-text">2025 - 2029</text>
<text x="190" y="312" class="content-text">Marketing Manager &amp; Specialist</text>
<text x="195" y="327" class="content-text">• "Neimo enisnot est atem Aham ipsum, finifam imbo sido. Intum lumina</text>
<text x="197" y="339" class="content-text">parco regia Aham extra mola idis. Commodo Aham antegor yumo</text>
<text x="197" y="351" class="content-text">finiam carius, domus serim lacur Aham. Mauris solitude Aham in</text>
<text x="197" y="363" class="content-text">albaidis, ceras vita Aham elit exidio leo, adipisci sector elit."</text>
<!-- Studio Shodwe -->
<circle cx="170" cy="385" r="4" class="timeline-dot"/>
<text x="190" y="380" class="content-bold">Studio Shodwe</text>
<text x="320" y="380" class="content-text">2024 - 2025</text>
<text x="190" y="392" class="content-text">Marketing Manager &amp; Specialist</text>
<text x="195" y="407" class="content-text">• Formulate and implement detailed marketing strategies and initiatives</text>
<text x="197" y="419" class="content-text">that support the company's mission and objectives. Guide, inspire, and</text>
<text x="197" y="431" class="content-text">oversee a dynamic marketing team, promoting a collaborative and</text>
<text x="197" y="443" class="content-text">performance-oriented culture. Ensure uniformity of the brand across</text>
<text x="197" y="455" class="content-text">all marketing platforms and materials.</text>
<!-- Education Section -->
<circle cx="170" cy="485" r="8" class="timeline-icon-bg"/>
<polygon points="166,481 174,481 170,489" class="timeline-icon"/>
<text x="190" y="475" class="section-title">EDUCATION</text>
<line x1="190" y1="483" x2="375" y2="483" class="divider"/>
<circle cx="170" cy="505" r="4" class="timeline-dot"/>
<text x="190" y="500" class="content-bold">Master of Business Management</text>
<text x="320" y="500" class="content-text">2029 - 2031</text>
<text x="190" y="512" class="content-text">School of business | Wardiere University</text>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96">
<rect x="1" y="1" width="158" height="94" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<!-- Header name and role -->
<rect x="12" y="12" width="96" height="8" fill="#111827"/>
<rect x="12" y="22" width="80" height="6" fill="#6b7280"/>
<line x1="12" y1="30" x2="148" y2="30" stroke="#e5e7eb"/>
<!-- Two columns: left sidebar blocks and right timeline -->
<!-- Left sidebar labels -->
<rect x="12" y="36" width="40" height="6" fill="#111827"/>
<rect x="12" y="46" width="40" height="6" fill="#111827"/>
<rect x="12" y="56" width="40" height="6" fill="#111827"/>
<rect x="12" y="66" width="40" height="6" fill="#111827"/>
<!-- Left sidebar items -->
<rect x="12" y="74" width="56" height="6" fill="#e5e7eb"/>
<rect x="12" y="82" width="56" height="6" fill="#e5e7eb"/>
<!-- Right column timeline vertical rule -->
<line x1="88" y1="36" x2="88" y2="86" stroke="#d1d5db" stroke-width="2"/>
<!-- Timeline bullets -->
<circle cx="88" cy="42" r="3" fill="#ffffff" stroke="#6b7280" stroke-width="2"/>
<circle cx="88" cy="58" r="3" fill="#ffffff" stroke="#6b7280" stroke-width="2"/>
<circle cx="88" cy="74" r="3" fill="#ffffff" stroke="#6b7280" stroke-width="2"/>
<!-- Right column items -->
<rect x="96" y="38" width="52" height="6" fill="#111827"/>
<rect x="96" y="46" width="52" height="6" fill="#e5e7eb"/>
<rect x="96" y="54" width="52" height="6" fill="#111827"/>
<rect x="96" y="62" width="52" height="6" fill="#e5e7eb"/>
<rect x="96" y="70" width="52" height="6" fill="#111827"/>
<rect x="96" y="78" width="52" height="6" fill="#e5e7eb"/>
<text x="12" y="10" font-size="10" fill="#374151" font-family="Inter, Arial">Timeline</text>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,28 @@
import React from 'react';
import { useSaveStatus, useLastSaved, useSaveError } from '../store/cvStore';
interface AutosaveStatusProps {
className?: string;
}
const AutosaveStatus: React.FC<AutosaveStatusProps> = ({ className = '' }) => {
const status = useSaveStatus();
const lastSaved = useLastSaved();
const error = useSaveError();
let text = '';
if (status === 'saving') text = 'Saving…';
if (status === 'saved') text = `Saved ${lastSaved ? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(lastSaved) : ''}`;
if (status === 'error') text = `Save error${error ? `: ${error}` : ''}`;
if (status === 'idle') text = 'All changes up to date';
const color = status === 'error' ? 'text-red-600' : status === 'saving' ? 'text-gray-600' : 'text-green-600';
return (
<div className={`text-sm ${color} ${className}`} aria-live="polite" role="status">
{text}
</div>
);
};
export default AutosaveStatus;

View File

@ -0,0 +1,151 @@
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 {
endpoint?: string;
className?: string;
}
const ExportControls: React.FC<ExportControlsProps> = ({ endpoint = 'http://localhost:4000/export/pdf', className = '' }) => {
const cv = useCvStore(state => state.cv);
const templateId = useCvStore(state => state.cv.templateId);
const updateTemplateId = useCvStore(state => state.updateTemplateId);
const [status, setStatus] = useState<'idle' | 'exporting' | 'error' | 'done'>('idle');
const [error, setError] = useState<string | null>(null);
const [lastFilename, setLastFilename] = useState<string | null>(null);
const [asyncMode, setAsyncMode] = useState<boolean>(true);
const currentJobId = useRef<string | null>(null);
async function handleExport() {
setStatus('exporting');
setError(null);
try {
if (!asyncMode) {
const { filename } = await exportPdf(cv, endpoint, templateId);
setLastFilename(filename);
setStatus('done');
setTimeout(() => setStatus('idle'), 1500);
return;
}
const jobId = await requestExportJob(cv, endpoint, templateId);
currentJobId.current = jobId;
const statusResult = await pollJobStatus(jobId, new URL(endpoint).origin);
if (statusResult.status === 'error') {
throw new Error(statusResult.error || 'Export job failed');
}
if (statusResult.status === 'canceled') {
setStatus('idle');
setError('Export canceled');
currentJobId.current = null;
return;
}
const { filename } = await downloadJob(jobId, new URL(endpoint).origin);
setLastFilename(filename);
setStatus('done');
setTimeout(() => setStatus('idle'), 1500);
} catch (err: unknown) {
let message = 'Export failed';
if (typeof err === 'object' && err !== null) {
const resp = (err as { response?: { data?: { error?: string } } }).response;
if (resp?.data?.error) {
message = resp.data.error;
} else if ('message' in err && typeof (err as { message?: string }).message === 'string') {
message = (err as { message?: string }).message as string;
}
}
setError(message);
setStatus('error');
}
}
async function handleCancel() {
if (!currentJobId.current) return;
try {
await cancelJob(currentJobId.current, new URL(endpoint).origin);
setStatus('idle');
setError(null);
currentJobId.current = null;
} catch (err) {
// Keep subtle; cancellation failures shouldn't block
console.warn('Cancel failed', err);
}
}
async function handleRetry() {
// reset and re-run export
setError(null);
await handleExport();
}
return (
<div className={`flex items-center gap-2 ${className}`}>
<label className="text-xs text-gray-600">Template</label>
<select
className="px-2 py-1 text-xs border rounded"
value={templateId}
onChange={e => updateTemplateId(e.target.value)}
disabled={status === 'exporting'}
>
{templatesRegistry.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<label className="ml-2 text-xs text-gray-600">Async</label>
<input
type="checkbox"
className="ml-1"
checked={asyncMode}
onChange={e => setAsyncMode(e.target.checked)}
disabled={status === 'exporting'}
/>
<button
type="button"
onClick={handleExport}
disabled={status === 'exporting'}
className={`px-3 py-1.5 rounded-md text-sm border ${status === 'exporting' ? 'bg-gray-300 text-gray-700 border-gray-300' : 'bg-green-600 text-white border-green-600 hover:bg-green-700'}`}
title="Export to PDF"
>
{status === 'exporting' ? (
<span className="inline-flex items-center gap-1">
<span className="animate-spin inline-block w-3 h-3 border-2 border-white border-t-transparent rounded-full" />
Exporting
</span>
) : 'Export PDF'}
</button>
{status === 'exporting' && asyncMode && (
<button
type="button"
onClick={handleCancel}
className="px-2 py-1 rounded-md text-xs border bg-gray-100 text-gray-800 border-gray-300 hover:bg-gray-200"
title="Cancel export job"
>
Cancel
</button>
)}
{status === 'error' && (
<button
type="button"
onClick={handleRetry}
className="px-2 py-1 rounded-md text-xs border bg-orange-500 text-white border-orange-500 hover:bg-orange-600"
title="Retry export"
>
Retry
</button>
)}
{status === 'error' && (
<span className="text-xs text-red-600">{error}</span>
)}
{status === 'done' && lastFilename && (
<span className="text-xs text-gray-600">Downloaded: {lastFilename}</span>
)}
</div>
);
};
export default ExportControls;

View File

@ -1,6 +1,10 @@
import React from 'react';
import { useCvStore } from '../store/cvStore';
import ATSTemplate from '../templates/ATSTemplate';
import React, { useMemo, useState } from 'react';
import { useCvStore, useColorTheme } from '../store/cvStore';
import { templatesMap } from '../templates/registry';
import { buildPrintableHtml } from '../utils/printable';
import ExportControls from './ExportControls';
import TemplateGallery from './TemplateGallery';
import ThemeProvider from './ThemeProvider';
interface PreviewPanelProps {
className?: string;
@ -9,20 +13,62 @@ interface PreviewPanelProps {
const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
const cv = useCvStore(state => state.cv);
const templateId = useCvStore(state => state.cv.templateId);
const colorTheme = useColorTheme();
const [mode, setMode] = useState<'inline' | 'printable'>('inline');
const printableHtml = useMemo(() => buildPrintableHtml(cv, templateId), [cv, templateId]);
return (
<div className={`bg-gray-100 rounded-lg shadow-inner overflow-auto ${className}`}>
<div className="sticky top-0 bg-gray-200 p-3 border-b flex justify-between items-center">
<h2 className="text-lg font-medium text-gray-700">Preview</h2>
<div className="flex items-center gap-3">
<div className="text-sm text-gray-500">
Template: <span className="font-medium">{templateId === 'ats' ? 'ATS-Friendly' : templateId}</span>
Template: <span className="font-medium">{templatesMap[templateId]?.name || templateId}</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setMode('inline')}
className={`px-2 py-1 rounded-md text-xs border ${mode === 'inline' ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
>
Inline
</button>
<button
type="button"
onClick={() => setMode('printable')}
className={`px-2 py-1 rounded-md text-xs border ${mode === 'printable' ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
>
Printable
</button>
</div>
<ExportControls className="ml-3" />
</div>
</div>
<div className="p-4">
{/* Render the appropriate template based on templateId */}
{templateId === 'ats' && <ATSTemplate cv={cv} />}
{/* Add more template options here as they are implemented */}
<div className="p-4 space-y-4">
<TemplateGallery />
{mode === 'inline' && (
<>
{/* Render component via registry */}
{(() => {
const Comp = templatesMap[templateId]?.component;
return Comp ? (
<ThemeProvider theme={colorTheme}>
<Comp cv={cv} />
</ThemeProvider>
) : null;
})()}
</>
)}
{mode === 'printable' && (
<iframe
title="Printable Preview"
className="w-full h-[calc(100vh-320px)] bg-white"
sandbox="allow-same-origin"
srcDoc={printableHtml}
/>
)}
</div>
</div>
);

View File

@ -10,6 +10,7 @@ const Stepper: React.FC<StepperProps> = ({ className = '' }) => {
const { setActiveStep, validateActiveSection, nextStep, prevStep } = useCvStore();
const steps: { id: EditorStep; label: string }[] = [
{ id: 'template', label: 'Choose Template' },
{ id: 'personal', label: 'Personal Details' },
{ id: 'work', label: 'Work Experience' },
{ id: 'education', label: 'Education' },
@ -86,10 +87,10 @@ const Stepper: React.FC<StepperProps> = ({ className = '' }) => {
<div className="flex justify-between mt-8">
<button
onClick={prevStep}
disabled={activeStep === 'personal'}
disabled={activeStep === 'template'}
className={`
px-4 py-2 rounded-md text-sm font-medium
${activeStep === 'personal' ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}
${activeStep === 'template' ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}
`}
>
Previous

View File

@ -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<TemplateGalleryProps> = ({ 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 (
<div className={`bg-white rounded-md border p-3 ${className}`}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-700">Templates</h3>
<div className="flex items-center gap-2">
{(['All', 'ATS', 'Visual'] as const).map(k => (
<button
key={k}
type="button"
onClick={() => setFilter(k)}
className={`px-2 py-1 rounded text-xs border ${filter === k ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
>
{k}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{filtered.map(t => (
<button
key={t.id}
type="button"
onClick={() => updateTemplateId(t.id)}
className={`group border rounded-md p-2 text-left ${templateId === t.id ? 'ring-2 ring-blue-600 border-blue-600' : 'border-gray-300'}`}
>
<div className="w-full h-24 bg-gray-50 flex items-center justify-center overflow-hidden rounded">
<img src={t.thumbnail} alt={`${t.name} thumbnail`} className="max-h-full" />
</div>
<div className="mt-2">
<div className="text-sm font-medium text-gray-800">{t.name}</div>
<div className="text-xs text-gray-500">{t.category}</div>
</div>
</button>
))}
</div>
</div>
);
};
export default TemplateGallery;

View File

@ -0,0 +1,24 @@
import React from 'react';
import { getThemeCSS, type ColorTheme } from '../types/colors';
interface ThemeProviderProps {
theme: ColorTheme;
children: React.ReactNode;
className?: string;
}
const ThemeProvider: React.FC<ThemeProviderProps> = ({ theme, children, className = '' }) => {
const themeStyles = getThemeCSS(theme);
const themeClass = theme ? `cv-theme-${theme}` : 'cv-theme-default';
return (
<div
className={`${themeClass} ${className}`}
style={themeStyles}
>
{children}
</div>
);
};
export default ThemeProvider;

View File

@ -1,10 +1,13 @@
import React, { useState, useEffect } from 'react';
import { usePersonalData, useCvStore } from '../store/cvStore';
import { usePersonalData, useCvStore, useTemplateId } from '../store/cvStore';
import { templatesMap } from '../templates/registry';
import { normalizeUrl } from '../schema/cvSchema';
const PersonalEditor: React.FC = () => {
const personalData = usePersonalData();
const { updatePersonal } = useCvStore();
const templateId = useTemplateId();
const supportsPhoto = !!templatesMap[templateId]?.supportsPhoto;
const [formData, setFormData] = useState(personalData);
const [errors, setErrors] = useState<Record<string, string>>({});
@ -40,6 +43,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
@ -94,6 +105,38 @@ const PersonalEditor: React.FC = () => {
updatePersonal(updatedFormData);
};
// Extras: Headline (extras[0]) and Reference lines (extras[1..])
const handleHeadlineChange = (value: string) => {
const extras = Array.isArray(formData.extras) ? [...formData.extras] : [];
extras[0] = value;
const updatedFormData = { ...formData, extras };
setFormData(updatedFormData);
};
const handleReferenceChange = (index: number, value: string) => {
const extras = Array.isArray(formData.extras) ? [...formData.extras] : [];
extras[index] = value;
const updatedFormData = { ...formData, extras };
setFormData(updatedFormData);
};
const handleAddReference = () => {
const extras = Array.isArray(formData.extras) ? [...formData.extras] : [];
extras.push("");
const updatedFormData = { ...formData, extras };
setFormData(updatedFormData);
};
const handleRemoveReference = (index: number) => {
const extras = Array.isArray(formData.extras) ? [...formData.extras] : [];
if (index >= 0 && index < extras.length) {
extras.splice(index, 1);
}
const updatedFormData = { ...formData, extras };
setFormData(updatedFormData);
updatePersonal(updatedFormData);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@ -214,6 +257,29 @@ const PersonalEditor: React.FC = () => {
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{/* Photo URL (only for templates that support photos) */}
{supportsPhoto && (
<div>
<label htmlFor="photoUrl" className="block text-sm font-medium text-gray-700 mb-1">
Photo URL
</label>
<input
type="url"
id="photoUrl"
name="photoUrl"
placeholder="https://example.com/photo.jpg"
value={formData.photoUrl || ''}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-3 py-2 border rounded-md ${errors.photoUrl ? 'border-red-500' : 'border-gray-300'}`}
aria-describedby={errors.photoUrl ? 'photoUrl-error' : undefined}
/>
{errors.photoUrl && (
<p id="photoUrl-error" className="mt-1 text-sm text-red-600">{errors.photoUrl}</p>
)}
</div>
)}
</div>
{/* Address Fields */}
@ -339,6 +405,63 @@ const PersonalEditor: React.FC = () => {
</div>
))}
</div>
{/* Headline & Reference Section */}
<div className="mt-6">
<h3 className="text-lg font-medium mb-2">Headline & Reference</h3>
{/* Headline */}
<div className="mb-4">
<label htmlFor="headline" className="block text-sm font-medium text-gray-700 mb-1">
Headline (under your name)
</label>
<input
type="text"
id="headline"
name="headline"
placeholder="e.g., Marketing Manager"
value={(formData.extras && formData.extras[0]) ? formData.extras[0] : ''}
onChange={(e) => handleHeadlineChange(e.target.value)}
onBlur={() => updatePersonal(formData)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{/* Reference Lines */}
<div className="flex justify-between items-center mb-2">
<h4 className="text-sm font-medium">Reference Lines (left sidebar)</h4>
<button
type="button"
onClick={handleAddReference}
className="px-3 py-1 bg-blue-50 text-blue-600 rounded-md text-sm hover:bg-blue-100"
>
Add Reference Line
</button>
</div>
{(formData.extras || []).slice(1).map((line, i) => (
<div key={`ref-${i}`} className="flex items-start space-x-2 mb-3">
<div className="flex-1">
<input
type="text"
placeholder={`Reference line ${i + 1}`}
value={line}
onChange={(e) => handleReferenceChange(i + 1, e.target.value)}
onBlur={() => updatePersonal(formData)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<button
type="button"
onClick={() => handleRemoveReference(i + 1)}
className="mt-2 text-red-500 hover:text-red-700"
aria-label="Remove reference line"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
</form>
</div>
);

View File

@ -3,10 +3,19 @@ import { useSkillsData, useCvStore } from '../store/cvStore';
const SkillsEditor: React.FC = () => {
const skills = useSkillsData();
const { addSkill, updateSkill, removeSkill } = useCvStore();
const { addSkill, updateSkill, removeSkill, reorderSkills } = useCvStore();
const [newSkillName, setNewSkillName] = useState('');
const [newSkillLevel, setNewSkillLevel] = useState<'Beginner' | 'Intermediate' | 'Advanced' | undefined>(undefined);
const levels = ['Beginner', 'Intermediate', 'Advanced'] as const;
type SkillLevel = typeof levels[number] | undefined;
const parseLevel = (value: string): SkillLevel => {
if (!value) return undefined;
return (levels.includes(value as SkillLevel extends undefined ? never : typeof levels[number])
? (value as typeof levels[number])
: undefined);
};
const handleAddSkill = () => {
const name = newSkillName.trim();
if (!name) return;
@ -34,7 +43,7 @@ const SkillsEditor: React.FC = () => {
<label className="block text-sm font-medium text-gray-700">Level (optional)</label>
<select
value={newSkillLevel || ''}
onChange={(e) => setNewSkillLevel((e.target.value as any) || undefined)}
onChange={(e) => setNewSkillLevel(parseLevel(e.target.value))}
className="px-3 py-2 border rounded-md border-gray-300"
>
<option value="">None</option>
@ -57,7 +66,7 @@ const SkillsEditor: React.FC = () => {
)}
<div className="space-y-2">
{skills.map((skill) => (
{skills.map((skill, idx) => (
<div key={skill.id} className="flex items-center gap-2">
<input
type="text"
@ -67,7 +76,7 @@ const SkillsEditor: React.FC = () => {
/>
<select
value={skill.level || ''}
onChange={(e) => updateSkill(skill.id, skill.name, (e.target.value as any) || undefined)}
onChange={(e) => updateSkill(skill.id, skill.name, parseLevel(e.target.value))}
className="px-3 py-2 border rounded-md border-gray-300"
>
<option value="">None</option>
@ -75,6 +84,26 @@ const SkillsEditor: React.FC = () => {
<option value="Intermediate">Intermediate</option>
<option value="Advanced">Advanced</option>
</select>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => reorderSkills(idx, Math.max(0, idx - 1))}
disabled={idx === 0}
className={`px-2 py-1 rounded-md text-sm ${idx === 0 ? 'bg-gray-200 text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
title="Move up"
>
Up
</button>
<button
type="button"
onClick={() => reorderSkills(idx, Math.min(skills.length - 1, idx + 1))}
disabled={idx === skills.length - 1}
className={`px-2 py-1 rounded-md text-sm ${idx === skills.length - 1 ? 'bg-gray-200 text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
title="Move down"
>
Down
</button>
</div>
<button
type="button"
onClick={() => removeSkill(skill.id)}

View File

@ -0,0 +1,125 @@
import React, { useEffect, useMemo } from 'react';
import { useSummaryData, useCvStore } from '../store/cvStore';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
interface SummaryEditorProps {}
const SummaryEditor: React.FC<SummaryEditorProps> = () => {
const summary = useSummaryData();
const { updateSummary, updateSummaryJson } = useCvStore();
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Briefly summarize your experience…',
includeChildren: true,
}),
],
content: summary || '',
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none',
},
},
onUpdate: ({ editor }) => {
const html = editor.getHTML();
updateSummary(html);
try {
const json = editor.getJSON();
updateSummaryJson(json);
} catch {
// ignore JSON persistence errors silently
}
},
});
// Keep editor content in sync if summary changes externally
useEffect(() => {
if (editor && summary !== editor.getHTML()) {
editor.commands.setContent(summary || '');
}
}, [summary, editor]);
const textCount = useMemo(() => {
return editor ? editor.getText().length : (summary || '').replace(/<[^>]*>/g, '').length;
}, [editor, summary]);
const isTooLong = textCount > 600;
const toggleMark = (mark: 'bold' | 'italic') => {
if (!editor) return;
if (mark === 'bold') editor.chain().focus().toggleBold().run();
if (mark === 'italic') editor.chain().focus().toggleItalic().run();
};
const toggleList = (type: 'bullet' | 'ordered') => {
if (!editor) return;
if (type === 'bullet') editor.chain().focus().toggleBulletList().run();
if (type === 'ordered') editor.chain().focus().toggleOrderedList().run();
};
const clearFormatting = () => {
if (!editor) return;
editor.chain().focus().clearNodes().unsetAllMarks().run();
};
return (
<div className="p-6 bg-white rounded-lg shadow-sm">
<h2 className="text-xl font-semibold mb-4">Professional Summary</h2>
<p className="text-sm text-gray-600 mb-4">Keep it concise (600 characters). Use formatting for readability.</p>
{/* Toolbar */}
<div className="flex items-center gap-2 mb-3">
<button
type="button"
onClick={() => toggleMark('bold')}
className={`px-3 py-1 rounded-md text-sm border ${editor?.isActive('bold') ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
>
Bold
</button>
<button
type="button"
onClick={() => toggleMark('italic')}
className={`px-3 py-1 rounded-md text-sm border ${editor?.isActive('italic') ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
>
Italic
</button>
<button
type="button"
onClick={() => toggleList('bullet')}
className={`px-3 py-1 rounded-md text-sm border ${editor?.isActive('bulletList') ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
>
Bullets
</button>
<button
type="button"
onClick={() => toggleList('ordered')}
className={`px-3 py-1 rounded-md text-sm border ${editor?.isActive('orderedList') ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
>
Numbered
</button>
<button
type="button"
onClick={clearFormatting}
className="px-3 py-1 rounded-md text-sm border bg-gray-100 text-gray-800 border-gray-300"
>
Clear
</button>
<div className={`ml-auto text-xs ${isTooLong ? 'text-red-600' : 'text-gray-500'}`}>{textCount}/600</div>
</div>
<div className={`border rounded-md p-3 ${isTooLong ? 'border-red-500' : 'border-gray-300'}`}>
<EditorContent editor={editor} />
</div>
{isTooLong && (
<p className="mt-2 text-sm text-red-600">Summary exceeds 600 characters. Please shorten it.</p>
)}
</div>
);
};
export default SummaryEditor;

View File

@ -0,0 +1,167 @@
import React, { useMemo, useState } from 'react';
import { templatesRegistry, getTemplateThumbnail, type TemplateCategory } from '../templates/registry';
import { useCvStore, useColorTheme } from '../store/cvStore';
import { colorThemes, colorThemeOrder, type ColorTheme } from '../types/colors';
const TemplateSelectionEditor: React.FC = () => {
const templateId = useCvStore(state => state.cv.templateId);
const updateTemplateId = useCvStore(state => state.updateTemplateId);
const colorTheme = useColorTheme();
const updateColorTheme = useCvStore(state => state.updateColorTheme);
const [filter, setFilter] = useState<'All' | TemplateCategory>('All');
const filtered = useMemo(() => {
return templatesRegistry.filter(t => filter === 'All' ? true : t.category === filter);
}, [filter]);
const handleTemplateSelect = (id: string) => {
updateTemplateId(id);
};
const handleColorSelect = (color: ColorTheme) => {
updateColorTheme(color);
};
return (
<div className="bg-white rounded-lg shadow-sm p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
What do you want your CV to look like?
</h1>
<p className="text-gray-600">
Scroll to view all styles and click to select a specific style.
</p>
</div>
{/* Color selection */}
<div className="flex justify-center gap-2 mb-8">
{colorThemeOrder.map(color => {
if (color === null) {
// Default/None option
return (
<button
key="default"
type="button"
onClick={() => handleColorSelect(null)}
className={`w-6 h-6 rounded-full border-2 border-gray-300 bg-white transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 flex items-center justify-center ${
colorTheme === null
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
: 'hover:ring-1 hover:ring-offset-1 hover:ring-gray-300'
}`}
aria-label="Select default color theme"
>
<span className="text-xs text-gray-500 font-medium">/</span>
</button>
);
}
const palette = colorThemes[color];
return (
<button
key={color}
type="button"
onClick={() => handleColorSelect(color)}
className={`w-6 h-6 rounded-full transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
colorTheme === color
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
: 'hover:ring-1 hover:ring-offset-1 hover:ring-gray-300'
}`}
style={{ backgroundColor: palette.primary }}
aria-label={`Select ${color} color theme`}
/>
);
})}
</div>
{/* Filter buttons */}
<div className="flex justify-center gap-4 mb-8">
{(['All', 'ATS', 'Visual'] as const).map(category => (
<button
key={category}
type="button"
onClick={() => setFilter(category)}
className={`px-6 py-2 rounded-full text-sm font-medium transition-colors ${
filter === category
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{category}
</button>
))}
</div>
{/* Template grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 max-w-7xl mx-auto">
{filtered.map(template => (
<div key={template.id} className="flex flex-col items-center">
<button
type="button"
onClick={() => handleTemplateSelect(template.id)}
className={`group relative w-full aspect-[3/4] border-2 rounded-lg overflow-hidden transition-all duration-200 hover:shadow-lg ${
templateId === template.id
? 'border-blue-600 ring-2 ring-blue-600 ring-opacity-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
{/* Template thumbnail */}
<div className="w-full h-full bg-gray-50 flex items-center justify-center">
<img
src={getTemplateThumbnail(template.id, colorTheme)}
alt={`${template.name} template preview`}
className="w-full h-full object-contain"
/>
</div>
{/* Selection indicator */}
{templateId === template.id && (
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
{/* Template info */}
<div className="mt-3 text-center">
<h3 className="font-semibold text-gray-900">{template.name}</h3>
{template.category === 'ATS' && (
<span className="inline-block mt-1 px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Recommended
</span>
)}
{template.category === 'Visual' && template.name === 'Smart' && (
<span className="inline-block mt-1 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
Recommended
</span>
)}
{template.category === 'Visual' && template.name === 'Traditional 2' && (
<span className="inline-block mt-1 px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded-full">
Recommended
</span>
)}
</div>
</div>
))}
</div>
{/* Additional templates section */}
<div className="mt-12 text-center">
<p className="text-gray-500 text-sm mb-4">
Need more options? Additional templates are available
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-2xl mx-auto opacity-60">
{/* Placeholder for additional templates */}
{[1, 2, 3, 4].map(i => (
<div key={i} className="aspect-[3/4] bg-gray-100 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
<span className="text-gray-400 text-xs">Coming Soon</span>
</div>
))}
</div>
</div>
</div>
);
};
export default TemplateSelectionEditor;

View File

@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react';
import { useCvStore } from '../store/cvStore';
import { validateCV } from '../schema/cvSchema';
export function useLocalAutosave(storageKey = 'cv-engine:draft', debounceMs = 800) {
const cv = useCvStore(s => s.cv);
const isDirty = useCvStore(s => s.isDirty);
const hydrate = useCvStore(s => s.hydrate);
const setSaveStatus = useCvStore(s => s.setSaveStatus);
const setSaveError = useCvStore(s => s.setSaveError);
const markSaved = useCvStore(s => s.markSaved);
const timerRef = useRef<number | null>(null);
// Load draft on mount
useEffect(() => {
try {
const raw = localStorage.getItem(storageKey);
const rawLastSaved = localStorage.getItem(`${storageKey}:lastSaved`);
if (raw) {
const parsed = JSON.parse(raw);
const result = validateCV(parsed);
if (result.success) {
const last = rawLastSaved ? new Date(rawLastSaved) : null;
hydrate(result.data, last ?? null);
setSaveStatus('saved');
} else {
setSaveError('Found invalid draft in storage; ignoring.');
setSaveStatus('error');
}
}
} catch (e: any) {
setSaveError('Failed to load draft from storage.');
setSaveStatus('error');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
// Debounced autosave when dirty
useEffect(() => {
if (!isDirty) return;
setSaveStatus('saving');
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
try {
const json = JSON.stringify(cv);
localStorage.setItem(storageKey, json);
const nowIso = new Date().toISOString();
localStorage.setItem(`${storageKey}:lastSaved`, nowIso);
markSaved(new Date(nowIso));
} catch (e: any) {
setSaveError(e?.message || 'Failed to save to storage.');
setSaveStatus('error');
}
}, debounceMs);
return () => {
if (timerRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [cv, isDirty, storageKey, debounceMs, setSaveStatus, setSaveError, markSaved]);
}

View File

@ -1,4 +1,20 @@
import { z } from 'zod';
import DOMPurify from 'dompurify';
// Initialize link-safety hook: force rel/target and restrict href schemes
// Note: Hooks may be registered multiple times during HMR; this hook is idempotent.
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node && node.nodeName && node.nodeName.toLowerCase() === 'a') {
const href = node.getAttribute('href') || '';
const isSafe = /^(https?:\/\/|mailto:)/i.test(href);
if (!isSafe) {
node.removeAttribute('href');
}
// Enforce safe link attributes
node.setAttribute('rel', 'noopener noreferrer');
node.setAttribute('target', '_blank');
}
});
// Helper function to generate unique IDs
export const generateId = () => Math.random().toString(36).substring(2, 9);
@ -10,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(),
@ -68,7 +85,10 @@ export const CvSchema = z.object({
date: z.string().optional()
})
).optional().default([]),
// Optional ProseMirror JSON for summary rich content persistence
summaryJson: z.any().optional(),
templateId: z.string().default('ats'),
colorTheme: z.enum(['gray', 'dark-gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'black'] as const).nullable().default(null),
});
// Type inference from the schema
@ -81,6 +101,7 @@ export const createEmptyCV = (): CV => ({
lastName: "",
email: "",
phone: "",
photoUrl: "",
street: "",
city: "",
country: "",
@ -94,7 +115,9 @@ export const createEmptyCV = (): CV => ({
skills: [],
languages: [],
certifications: [],
summaryJson: undefined,
templateId: "ats",
colorTheme: null,
});
// Helper functions for validation
@ -109,12 +132,12 @@ export const validateSection = <K extends keyof CV>(section: K, data: CV[K]) =>
// Sanitization helpers
export const sanitizeHtml = (html: string): string => {
// In a real implementation, we would use DOMPurify here
// This is a simple placeholder
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/<[^>]*>/g, ''); // Remove all HTML tags for now
// Sanitize summary while preserving basic formatting elements
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
ALLOWED_ATTR: ['href', 'rel', 'target'],
ALLOW_UNKNOWN_PROTOCOLS: false
});
};
export const escapeText = (text: string): string => {

View File

@ -1,9 +1,10 @@
import { create } from 'zustand';
import { z } from 'zod';
import { CvSchema, createEmptyCV, validateSection } from '../schema/cvSchema';
import type { ColorTheme } from '../types/colors';
// Define the steps for the CV editor
export type EditorStep = 'personal' | 'work' | 'education' | 'skills' | 'summary' | 'finalize';
export type EditorStep = 'template' | 'personal' | 'work' | 'education' | 'skills' | 'summary' | 'finalize';
// Define the store state
interface CvState {
@ -13,16 +14,21 @@ interface CvState {
activeStep: EditorStep;
isDirty: boolean;
lastSaved: Date | null;
saveStatus: 'idle' | 'saving' | 'saved' | 'error';
saveError: string | null;
errors: Record<string, string[]>;
// Actions
setCv: (cv: z.infer<typeof CvSchema>) => void;
hydrate: (cv: z.infer<typeof CvSchema>, lastSaved?: Date | null) => void;
updatePersonal: (personal: z.infer<typeof CvSchema>['personal']) => void;
updateWork: (work: z.infer<typeof CvSchema>['work']) => void;
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
updateSkills: (skills: z.infer<typeof CvSchema>['skills']) => void;
updateSummary: (summary: string) => void;
updateSummaryJson: (json: unknown) => void;
updateTemplateId: (templateId: string) => void;
updateColorTheme: (colorTheme: ColorTheme) => void;
// Work item operations
addWorkItem: () => void;
@ -40,6 +46,7 @@ interface CvState {
addSkill: (name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
updateSkill: (id: string, name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
removeSkill: (id: string) => void;
reorderSkills: (fromIndex: number, toIndex: number) => void;
// Navigation
setActiveStep: (step: EditorStep) => void;
@ -49,6 +56,9 @@ interface CvState {
// State management
setDirty: (isDirty: boolean) => void;
setLastSaved: (date: Date | null) => void;
setSaveStatus: (status: CvState['saveStatus']) => void;
setSaveError: (error: string | null) => void;
markSaved: (date?: Date) => void;
validateActiveSection: () => boolean;
resetErrors: () => void;
}
@ -57,14 +67,18 @@ interface CvState {
export const useCvStore = create<CvState>((set, get) => ({
// Initial state
cv: createEmptyCV(),
activeStep: 'personal',
activeStep: 'template',
isDirty: false,
lastSaved: null,
saveStatus: 'idle',
saveError: null,
errors: {},
// Actions
setCv: (cv) => set({ cv, isDirty: true }),
hydrate: (cv, lastSaved) => set({ cv, isDirty: false, lastSaved: lastSaved ?? null }),
updatePersonal: (personal) => {
const result = validateSection('personal', personal);
if (result.success) {
@ -111,13 +125,27 @@ export const useCvStore = create<CvState>((set, get) => ({
}));
},
updateTemplateId: (templateId) => {
updateSummaryJson: (json) => {
set((state) => ({
cv: { ...state.cv, summaryJson: json },
isDirty: true
}));
},
updateTemplateId: (templateId) => {
set(state => ({
cv: { ...state.cv, templateId },
isDirty: true
}));
},
updateColorTheme: (colorTheme) => {
set(state => ({
cv: { ...state.cv, colorTheme },
isDirty: true
}));
},
// Work item operations
addWorkItem: () => {
const { cv } = get();
@ -242,12 +270,23 @@ export const useCvStore = create<CvState>((set, get) => ({
});
},
reorderSkills: (fromIndex, toIndex) => {
const { cv } = get();
const skills = [...cv.skills];
const [removed] = skills.splice(fromIndex, 1);
skills.splice(toIndex, 0, removed);
set({
cv: { ...cv, skills },
isDirty: true
});
},
// Navigation
setActiveStep: (activeStep) => set({ activeStep }),
nextStep: () => {
const { activeStep } = get();
const steps: EditorStep[] = ['personal', 'work', 'education', 'skills', 'summary', 'finalize'];
const steps: EditorStep[] = ['template', 'personal', 'work', 'education', 'skills', 'summary', 'finalize'];
const currentIndex = steps.indexOf(activeStep);
if (currentIndex < steps.length - 1) {
set({ activeStep: steps[currentIndex + 1] });
@ -256,7 +295,7 @@ export const useCvStore = create<CvState>((set, get) => ({
prevStep: () => {
const { activeStep } = get();
const steps: EditorStep[] = ['personal', 'work', 'education', 'skills', 'summary', 'finalize'];
const steps: EditorStep[] = ['template', 'personal', 'work', 'education', 'skills', 'summary', 'finalize'];
const currentIndex = steps.indexOf(activeStep);
if (currentIndex > 0) {
set({ activeStep: steps[currentIndex - 1] });
@ -266,6 +305,9 @@ export const useCvStore = create<CvState>((set, get) => ({
// State management
setDirty: (isDirty) => set({ isDirty }),
setLastSaved: (lastSaved) => set({ lastSaved }),
setSaveStatus: (saveStatus) => set({ saveStatus }),
setSaveError: (saveError) => set({ saveError }),
markSaved: (date) => set({ lastSaved: date ?? new Date(), isDirty: false, saveStatus: 'saved', saveError: null }),
validateActiveSection: () => {
const { activeStep, cv } = get();
@ -273,6 +315,20 @@ export const useCvStore = create<CvState>((set, get) => ({
// Map step to CV section
let isValid = true;
if (activeStep === 'template') {
// Ensure a template is selected
isValid = !!cv.templateId;
if (!isValid) {
set((state) => ({
errors: {
...state.errors,
template: ['Please select a template to continue']
}
}));
} else {
set((state) => ({ errors: { ...state.errors, template: [] } }));
}
}
if (activeStep === 'personal') {
const result = validateSection('personal', cv.personal);
isValid = result.success;
@ -327,6 +383,20 @@ export const useCvStore = create<CvState>((set, get) => ({
set((state) => ({ errors: { ...state.errors, skills: [] } }));
}
}
if (activeStep === 'summary') {
const result = validateSection('summary', cv.summary);
isValid = result.success;
if (!result.success) {
set((state) => ({
errors: {
...state.errors,
summary: result.error.issues.map(i => i.message)
}
}));
} else {
set((state) => ({ errors: { ...state.errors, summary: [] } }));
}
}
return isValid;
},
@ -340,7 +410,11 @@ export const useWorkData = () => useCvStore(state => state.cv.work);
export const useEducationData = () => useCvStore(state => state.cv.education);
export const useSkillsData = () => useCvStore(state => state.cv.skills);
export const useSummaryData = () => useCvStore(state => state.cv.summary);
export const useSummaryJson = () => useCvStore(state => state.cv.summaryJson);
export const useTemplateId = () => useCvStore(state => state.cv.templateId);
export const useColorTheme = () => useCvStore(state => state.cv.colorTheme);
export const useActiveStep = () => useCvStore(state => state.activeStep);
export const useIsDirty = () => useCvStore(state => state.isDirty);
export const useLastSaved = () => useCvStore(state => state.lastSaved);
export const useSaveStatus = () => useCvStore(state => state.saveStatus);
export const useSaveError = () => useCvStore(state => state.saveError);

View File

@ -36,95 +36,63 @@ const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
const { personal, summary, work, education, skills, languages, certifications } = cv;
return (
<div className={`bg-white text-gray-800 p-8 max-w-4xl mx-auto ${className}`}>
<div
className={`p-8 max-w-4xl mx-auto ${className}`}
style={{
backgroundColor: 'var(--theme-background, #ffffff)',
color: 'var(--theme-text, #1f2937)',
fontFamily: 'serif',
lineHeight: 'normal'
}}
>
{/* Header */}
<header className="mb-6 border-b pb-6">
<h1 className="text-3xl font-bold mb-1">
<header className="text-center mb-6">
<h1 className="text-2xl font-bold mb-1">
{escapeText(personal.firstName)} {escapeText(personal.lastName)}
</h1>
<div className="flex flex-wrap gap-3 text-sm mt-2">
{personal.email && (
<div className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>{escapeText(personal.email)}</span>
</div>
)}
{personal.phone && (
<div className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span>{escapeText(personal.phone)}</span>
</div>
)}
{(personal.city || personal.country) && (
<div className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>
{personal.city && escapeText(personal.city)}
{personal.city && personal.country && ', '}
{personal.country && escapeText(personal.country)}
</span>
</div>
)}
{personal.links.map((link) => (
<div key={link.id} className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{escapeText(link.label || link.url)}
</a>
</div>
))}
<div className="text-xs">
{[
personal.city && personal.country && `${escapeText(personal.city)}, ${escapeText(personal.country)}`,
personal.phone && escapeText(personal.phone),
personal.email && escapeText(personal.email),
...personal.links.map(link => escapeText(link.label || link.url))
].filter(Boolean).join(' • ')}
</div>
</header>
{/* Summary */}
{summary && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-2 text-gray-700 border-b pb-1">Professional Summary</h2>
<p className="text-gray-700" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
<section className="mb-4">
<h2 className="text-sm font-bold mb-2 pb-1 border-b border-black uppercase">
Professional Summary
</h2>
<p className="text-xs leading-normal" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
</section>
)}
{/* Work Experience */}
{work.length > 0 && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Work Experience</h2>
<section className="mb-4">
<h2 className="text-sm font-bold mb-2 pb-1 border-b border-black uppercase">Professional Experience</h2>
<div className="space-y-4">
<div className="space-y-3">
{work.map((job) => (
<div key={job.id} className="mb-4">
<div className="flex justify-between items-start">
<div key={job.id} className="mb-3">
<div className="flex justify-between items-start mb-1">
<div>
<h3 className="text-lg font-semibold">{escapeText(job.title)}</h3>
<p className="text-gray-700">{escapeText(job.company)}</p>
<h3 className="font-bold text-xs">{escapeText(job.company)}, {formatLocation(job.location)}</h3>
<p className="text-xs italic">{escapeText(job.title)}</p>
</div>
<div className="text-sm text-gray-600">
{formatDate(job.startDate)} - {job.endDate ? formatDate(job.endDate) : 'Present'}
{job.location && <div>{formatLocation(job.location)}</div>}
<div className="text-xs">
{formatDate(job.startDate)} {job.endDate ? formatDate(job.endDate) : 'Present'}
</div>
</div>
{job.bullets.length > 0 && (
<ul className="list-disc pl-5 mt-2 space-y-1">
<ul className="list-disc pl-4 mt-1 space-y-0.5">
{job.bullets.map((bullet, index) => (
<li key={index} className="text-gray-700">{escapeText(bullet)}</li>
<li key={index} className="text-xs leading-normal">{escapeText(bullet)}</li>
))}
</ul>
)}
@ -136,23 +104,23 @@ const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
{/* Education */}
{education.length > 0 && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Education</h2>
<section className="mb-4">
<h2 className="text-sm font-bold mb-2 pb-1 border-b border-black uppercase">Education</h2>
<div className="space-y-4">
<div className="space-y-2">
{education.map((edu) => (
<div key={edu.id} className="mb-4">
<div key={edu.id}>
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold">{escapeText(edu.degree)}</h3>
<p className="text-gray-700">{escapeText(edu.school)}</p>
<h3 className="font-bold text-xs">{escapeText(edu.school)}</h3>
<p className="text-xs italic">{escapeText(edu.degree)}</p>
</div>
<div className="text-sm text-gray-600">
{formatDate(edu.startDate)} - {edu.endDate ? formatDate(edu.endDate) : 'Present'}
<div className="text-xs">
{edu.endDate ? formatDate(edu.endDate) : formatDate(edu.startDate)}
</div>
</div>
{edu.notes && <p className="mt-1 text-gray-700">{escapeText(edu.notes)}</p>}
{edu.notes && <p className="mt-1 text-xs">{escapeText(edu.notes)}</p>}
</div>
))}
</div>
@ -161,18 +129,15 @@ const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
{/* Skills */}
{skills.length > 0 && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Skills</h2>
<section className="mb-4">
<h2 className="text-sm font-bold mb-2 pb-1 border-b border-black uppercase">Skills</h2>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-x-3 gap-y-1">
{skills.map((skill) => (
<span
key={skill.id}
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
>
{escapeText(skill.name)}
{skill.level && <span className="ml-1 text-gray-500">({skill.level})</span>}
</span>
<div key={skill.id} className="flex items-center">
<span className="text-xs font-bold mr-1"></span>
<span className="text-xs">{escapeText(skill.name)}</span>
</div>
))}
</div>
</section>

View File

@ -0,0 +1,176 @@
import React from 'react';
import type { CV } from '../schema/cvSchema';
import { sanitizeHtml } from '../schema/cvSchema';
interface ClassicTemplateProps {
cv: CV;
className?: string;
}
// Simple classic visual template for inline preview
const ClassicTemplate: React.FC<ClassicTemplateProps> = ({ cv, className = '' }) => {
const personal = cv.personal || {};
const summary = cv.summary || '';
const work = cv.work || [];
const education = cv.education || [];
const skills = cv.skills || [];
const languages = cv.languages || [];
const certifications = cv.certifications || [];
return (
<div
className={`p-6 rounded-md shadow ${className}`}
style={{
backgroundColor: 'var(--theme-background, #ffffff)',
color: 'var(--theme-text, #1f2937)'
}}
>
<header
className="pb-3 mb-4"
style={{ borderBottomColor: 'var(--theme-primary, #2563eb)', borderBottomWidth: '2px' }}
>
<h1 className="text-2xl font-semibold">
{personal.firstName} {personal.lastName}
</h1>
<div className="mt-1 text-sm text-gray-600 flex flex-wrap gap-2">
{personal.email && <span>{personal.email}</span>}
{personal.phone && <span>{personal.phone}</span>}
{(personal.city || personal.country) && (
<span>
{personal.city}{personal.city && personal.country ? ', ' : ''}{personal.country}
</span>
)}
</div>
</header>
{summary && (
<section className="mb-4">
<h2
className="text-lg font-medium mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Professional Summary
</h2>
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
</section>
)}
{work.length > 0 && (
<section className="mb-4">
<h2
className="text-lg font-medium mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Work Experience
</h2>
<div className="space-y-3">
{work.map((job) => (
<article key={job.id} className="">
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold">{job.title}</h3>
<div className="text-sm text-gray-600">{job.company}</div>
</div>
<div className="text-sm text-gray-600">
{job.startDate} - {job.endDate || 'Present'}
{job.location && <div>{job.location}</div>}
</div>
</div>
{job.bullets && job.bullets.length > 0 && (
<ul className="list-disc pl-6 mt-2">
{job.bullets.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
)}
</article>
))}
</div>
</section>
)}
{education.length > 0 && (
<section className="mb-4">
<h2
className="text-lg font-medium mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Education
</h2>
<div className="space-y-3">
{education.map((ed) => (
<article key={ed.id}>
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold">{ed.degree}</h3>
<div className="text-sm text-gray-600">{ed.school}</div>
</div>
<div className="text-sm text-gray-600">
{ed.startDate} {ed.endDate && <>- {ed.endDate}</>}
</div>
</div>
{/* GPA not present in schema; omit for now */}
</article>
))}
</div>
</section>
)}
{skills.length > 0 && (
<section className="mb-4">
<h2
className="text-lg font-medium mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Skills
</h2>
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<span
key={s.id}
className="px-2 py-1 rounded-full text-sm"
style={{
backgroundColor: 'var(--theme-accent, #eff6ff)',
color: 'var(--theme-primary, #2563eb)'
}}
>
{s.name}{s.level ? `${s.level}` : ''}
</span>
))}
</div>
</section>
)}
{languages.length > 0 && (
<section className="mb-4">
<h2
className="text-lg font-medium mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Languages
</h2>
<div className="flex flex-wrap gap-2">
{languages.map((l) => (
<span key={l.id} className="px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-sm">
{l.name}{l.level ? `${l.level}` : ''}
</span>
))}
</div>
</section>
)}
{certifications.length > 0 && (
<section className="mb-2">
<h2 className="text-lg font-medium text-gray-700 mb-2">Certifications</h2>
<ul className="list-disc pl-6">
{certifications.map((c) => (
<li key={c.id}>{c.name}{c.issuer ? ` - ${c.issuer}` : ''}{c.date ? ` (${c.date})` : ''}</li>
))}
</ul>
</section>
)}
</div>
);
};
export default ClassicTemplate;

View File

@ -0,0 +1,169 @@
import React from 'react';
import type { CV } from '../schema/cvSchema';
import { sanitizeHtml } from '../schema/cvSchema';
interface MinimalTemplateProps { cv: CV; className?: string; }
// Minimal clean template with generous whitespace and serif headings
const MinimalTemplate: React.FC<MinimalTemplateProps> = ({ cv, className = '' }) => {
const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv;
return (
<div
className={`max-w-3xl mx-auto px-8 py-6 ${className}`}
style={{
fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial',
backgroundColor: 'var(--theme-background, #ffffff)',
color: 'var(--theme-text, #1f2937)'
}}
>
<header className="mb-6">
<h1 className="text-3xl font-serif tracking-tight">{(personal?.firstName || '')} {(personal?.lastName || '')}</h1>
<div className="mt-2 text-sm text-gray-600 flex flex-wrap gap-2">
{personal?.email && <span>{personal.email}</span>}
{personal?.phone && <span>{personal.phone}</span>}
{(personal?.city || personal?.country) && (
<span>
{personal.city}{personal.city && personal.country ? ', ' : ''}{personal.country}
</span>
)}
</div>
</header>
{summary && (
<section className="mb-6">
<h2
className="text-xl font-serif mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Summary
</h2>
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
</section>
)}
{work.length > 0 && (
<section className="mb-6">
<h2
className="text-xl font-serif mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Experience
</h2>
<div className="space-y-4">
{work.map((job) => (
<article key={job.id}>
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold">{job.title}</h3>
<div className="text-sm text-gray-600">{job.company}</div>
</div>
<div className="text-sm text-gray-600 text-right">
{job.startDate} - {job.endDate || 'Present'}
{job.location && <div>{job.location}</div>}
</div>
</div>
{job.bullets && job.bullets.length > 0 && (
<ul className="list-disc pl-6 mt-2">
{job.bullets.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
)}
</article>
))}
</div>
</section>
)}
{education.length > 0 && (
<section className="mb-6">
<h2
className="text-xl font-serif mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Education
</h2>
<div className="space-y-3">
{education.map((ed) => (
<article key={ed.id}>
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold">{ed.degree}</h3>
<div className="text-sm text-gray-600">{ed.school}</div>
</div>
<div className="text-sm text-gray-600">{ed.startDate}{ed.endDate && <> - {ed.endDate}</>}</div>
</div>
</article>
))}
</div>
</section>
)}
{(skills.length > 0 || languages.length > 0 || certifications.length > 0) && (
<section className="grid grid-cols-1 md:grid-cols-3 gap-4">
{skills.length > 0 && (
<div>
<h3
className="text-base font-serif mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Skills
</h3>
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<span
key={s.id}
className="px-2 py-1 rounded text-sm"
style={{
backgroundColor: 'var(--theme-accent, #eff6ff)',
color: 'var(--theme-primary, #2563eb)'
}}
>
{s.name}{s.level ? `${s.level}` : ''}
</span>
))}
</div>
</div>
)}
{languages.length > 0 && (
<div>
<h3
className="text-base font-serif mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Languages
</h3>
<div className="flex flex-wrap gap-2">
{languages.map((l) => (
<span
key={l.id}
className="px-2 py-1 rounded text-sm"
style={{
backgroundColor: 'var(--theme-secondary, #3b82f6)',
color: 'white'
}}
>
{l.name}{l.level ? `${l.level}` : ''}
</span>
))}
</div>
</div>
)}
{certifications.length > 0 && (
<div>
<h3 className="text-base font-serif mb-2">Certifications</h3>
<ul className="list-disc pl-6">
{certifications.map((c) => (
<li key={c.id}>{c.name}{c.issuer ? ` - ${c.issuer}` : ''}{c.date ? ` (${c.date})` : ''}</li>
))}
</ul>
</div>
)}
</section>
)}
</div>
);
};
export default MinimalTemplate;

View File

@ -0,0 +1,185 @@
import React from 'react';
import type { CV } from '../schema/cvSchema';
import { sanitizeHtml } from '../schema/cvSchema';
interface ModernTemplateProps { cv: CV; className?: string; }
// Modern visual template with accent header and two-column sections
const ModernTemplate: React.FC<ModernTemplateProps> = ({ cv, className = '' }) => {
const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv;
return (
<div
className={`max-w-4xl mx-auto ${className}`}
style={{
backgroundColor: 'var(--theme-background, #ffffff)',
color: 'var(--theme-text, #1f2937)'
}}
>
<div
className="text-white p-6 flex items-center gap-4"
style={{ backgroundColor: 'var(--theme-primary, #2563eb)' }}
>
{personal?.photoUrl && (
<img
src={personal.photoUrl}
alt={`${personal.firstName || ''} ${personal.lastName || ''} photo`}
className="w-16 h-16 rounded-full object-cover border-2 border-white/70"
referrerPolicy="no-referrer"
/>
)}
<h1 className="text-3xl font-bold">
{(personal?.firstName || '')} {(personal?.lastName || '')}
</h1>
<div className="mt-2 text-sm opacity-90 flex flex-wrap gap-3">
{personal.email && <span>{personal.email}</span>}
{personal.phone && <span>{personal.phone}</span>}
{(personal.city || personal.country) && (
<span>
{personal.city}{personal.city && personal.country ? ', ' : ''}{personal.country}
</span>
)}
</div>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2">
{summary && (
<section className="mb-6">
<h2
className="text-xl font-semibold mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Professional Summary
</h2>
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
</section>
)}
{work.length > 0 && (
<section className="mb-6">
<h2
className="text-xl font-semibold mb-3"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Work Experience
</h2>
<div className="space-y-4">
{work.map((job) => (
<article key={job.id} className="">
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold">{job.title}</h3>
<div className="text-sm text-gray-600">{job.company}</div>
</div>
<div className="text-sm text-gray-600 text-right">
{job.startDate} - {job.endDate || 'Present'}
{job.location && <div>{job.location}</div>}
</div>
</div>
{job.bullets && job.bullets.length > 0 && (
<ul className="list-disc pl-6 mt-2">
{job.bullets.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
)}
</article>
))}
</div>
</section>
)}
{education.length > 0 && (
<section className="mb-6">
<h2
className="text-xl font-semibold mb-3"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Education
</h2>
<div className="space-y-3">
{education.map((ed) => (
<article key={ed.id}>
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold">{ed.degree}</h3>
<div className="text-sm text-gray-600">{ed.school}</div>
</div>
<div className="text-sm text-gray-600">{ed.startDate}{ed.endDate && <> - {ed.endDate}</>}</div>
</div>
</article>
))}
</div>
</section>
)}
</div>
<div>
{skills.length > 0 && (
<section className="mb-6">
<h2
className="text-lg font-semibold mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Skills
</h2>
<div className="flex flex-wrap gap-2">
{skills.map((s) => (
<span
key={s.id}
className="px-2 py-1 rounded-full text-sm"
style={{
backgroundColor: 'var(--theme-accent, #eff6ff)',
color: 'var(--theme-primary, #2563eb)'
}}
>
{s.name}{s.level ? `${s.level}` : ''}
</span>
))}
</div>
</section>
)}
{languages.length > 0 && (
<section className="mb-6">
<h2
className="text-lg font-semibold mb-2"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Languages
</h2>
<div className="flex flex-wrap gap-2">
{languages.map((l) => (
<span
key={l.id}
className="px-2 py-1 rounded-full text-sm"
style={{
backgroundColor: 'var(--theme-secondary, #3b82f6)',
color: 'white'
}}
>
{l.name}{l.level ? `${l.level}` : ''}
</span>
))}
</div>
</section>
)}
{certifications.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-gray-700 mb-2">Certifications</h2>
<ul className="list-disc pl-6">
{certifications.map((c) => (
<li key={c.id}>{c.name}{c.issuer ? ` - ${c.issuer}` : ''}{c.date ? ` (${c.date})` : ''}</li>
))}
</ul>
</section>
)}
</div>
</div>
</div>
);
};
export default ModernTemplate;

View File

@ -0,0 +1,276 @@
import React from 'react';
import type { CV } from '../schema/cvSchema';
import { escapeText, sanitizeHtml } from '../schema/cvSchema';
interface TimelineTemplateProps { cv: CV; className?: string; }
// Date formatter supporting YYYY-MM and ISO
const formatDate = (dateStr: string): string => {
if (!dateStr) return '';
try {
if (/^\d{4}-\d{2}$/.test(dateStr)) {
const [year, month] = dateStr.split('-');
const d = new Date(parseInt(year), parseInt(month) - 1);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
}
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
} catch {
return dateStr;
}
};
const TimelineTemplate: React.FC<TimelineTemplateProps> = ({ cv, className = '' }) => {
const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv;
const headline = (personal.extras && personal.extras[0]) ? personal.extras[0] : '';
return (
<div
className={`max-w-4xl mx-auto px-6 py-6 ${className}`}
style={{
fontFamily: 'Arial, sans-serif',
backgroundColor: 'var(--theme-background, #ffffff)',
color: 'var(--theme-text, #1f2937)'
}}
>
{/* Header */}
<header className="mb-8">
<h1
className="text-3xl font-bold tracking-tight uppercase"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
{escapeText(personal.firstName || '')} {escapeText(personal.lastName || '')}
</h1>
{headline && <div className="mt-1 text-sm tracking-widest text-gray-600 uppercase">{escapeText(headline)}</div>}
<div className="mt-4 border-t border-gray-300" />
</header>
{/* Body: two columns with exact 30%/70% split */}
<div className="flex gap-8">
{/* Left sidebar - 30% width */}
<aside className="space-y-6" style={{ width: '30%' }}>
{/* Contact */}
<section>
<h2
className="text-xs font-bold tracking-widest uppercase"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Contact
</h2>
<div className="mt-2 border-t border-gray-300" />
<div className="mt-3 space-y-3 text-xs text-gray-700">
{personal.phone && (
<div className="flex items-start gap-2">
<div className="w-2 h-2 rounded-full bg-gray-600 mt-1.5 flex-shrink-0" />
<span>{escapeText(personal.phone)}</span>
</div>
)}
{personal.email && (
<div className="flex items-start gap-2">
<div className="w-2 h-2 rounded-full bg-gray-600 mt-1.5 flex-shrink-0" />
<span>{escapeText(personal.email)}</span>
</div>
)}
{(personal.street || personal.city || personal.country || personal.postcode) && (
<div className="flex items-start gap-2">
<div className="w-2 h-2 rounded-full bg-gray-600 mt-1.5 flex-shrink-0" />
<span>
{escapeText(personal.street || '')}
{(personal.street && (personal.city || personal.country || personal.postcode)) ? ', ' : ''}
{escapeText(personal.city || '')}
{personal.city && personal.country ? ', ' : ''}
{escapeText(personal.country || '')}
{personal.postcode ? ` ${escapeText(personal.postcode)}` : ''}
</span>
</div>
)}
{(personal.links || []).map(link => (
<div key={link.id} className="flex items-start gap-2">
<div className="w-2 h-2 rounded-full bg-gray-600 mt-1.5 flex-shrink-0" />
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-gray-700 hover:underline">
{escapeText(link.label || link.url)}
</a>
</div>
))}
</div>
</section>
{/* Skills */}
{skills.length > 0 && (
<section>
<h2
className="text-xs font-bold tracking-widest uppercase"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Skills
</h2>
<div className="mt-2 border-t border-gray-300" />
<ul className="mt-3 space-y-2 text-xs text-gray-700">
{skills.map(s => (
<li key={s.id} className="flex items-start gap-2">
<span className="text-gray-700"></span>
<span>
{escapeText(s.name)}{s.level ? ` (${escapeText(s.level)})` : ''}
</span>
</li>
))}
</ul>
</section>
)}
{/* Languages */}
{languages.length > 0 && (
<section>
<h2
className="text-xs font-bold tracking-widest uppercase"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Languages
</h2>
<div className="mt-2 border-t border-gray-300" />
<ul className="mt-3 space-y-2 text-xs text-gray-700">
{languages.map(l => (
<li key={l.id} className="flex items-start gap-2">
<span className="text-gray-700"></span>
<span>{escapeText(l.name)}{l.level ? ` (${escapeText(l.level)})` : ''}</span>
</li>
))}
</ul>
</section>
)}
{/* Reference */}
<section>
<h2
className="text-xs font-bold tracking-widest uppercase"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Reference
</h2>
<div className="mt-2 border-t border-gray-300" />
<div className="mt-3 space-y-1 text-xs text-gray-700">
<div className="font-semibold text-gray-800">Estelle Darcy</div>
<div>Wardiere Inc. / CTO</div>
<div>Phone: +124-4236-7894</div>
<div>Email: hello@ahmedd.saaahh.com</div>
</div>
</section>
{/* Certifications (optional sidebar) */}
{certifications.length > 0 && (
<section>
<h2
className="text-sm font-bold tracking-widest uppercase"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Certifications
</h2>
<ul className="mt-2 list-disc pl-5 text-gray-800 space-y-1">
{certifications.map(c => (
<li key={c.id}>{escapeText(c.name)}{c.issuer ? ` - ${escapeText(c.issuer)}` : ''}{c.date ? ` (${escapeText(c.date)})` : ''}</li>
))}
</ul>
</section>
)}
</aside>
{/* Right Column - Main Content */}
<div className="flex-1 pl-8 relative">
{/* Vertical Timeline Line */}
<div className="absolute left-0 top-0 bottom-0 w-px bg-gray-300" />
{/* Profile */}
{summary && (
<section className="mb-8 relative">
<div className="flex items-center gap-3 mb-4">
<div className="absolute -left-[21px] w-3 h-3 rounded-full bg-gray-800 border-2 border-white" />
<h2
className="text-sm font-bold uppercase tracking-widest"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Profile
</h2>
</div>
<p className="text-xs text-gray-700 leading-relaxed" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
</section>
)}
{/* Work Experience */}
{work.length > 0 && (
<section className="mb-8 relative">
<div className="flex items-center gap-3 mb-6">
<div className="absolute -left-[21px] w-3 h-3 rounded-full bg-gray-800 border-2 border-white" />
<h2
className="text-sm font-bold uppercase tracking-widest"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Work Experience
</h2>
</div>
<div className="space-y-6">
{work.map(job => (
<div key={job.id} className="relative">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="text-sm font-bold text-gray-800">{escapeText(job.company)}</h3>
<p className="text-xs text-gray-600">{escapeText(job.title)}</p>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">
{formatDate(job.startDate)} - {job.endDate ? formatDate(job.endDate) : 'Present'}
</span>
</div>
{job.bullets && job.bullets.length > 0 && (
<div className="text-xs text-gray-700 space-y-1">
{job.bullets.map((b, idx) => (
<div key={idx}> {escapeText(b)}</div>
))}
</div>
)}
{job.location && (
<div className="mt-1 text-xs text-gray-600">{escapeText(job.location)}</div>
)}
</div>
))}
</div>
</section>
)}
{/* Education */}
{education.length > 0 && (
<section className="relative">
<div className="flex items-center gap-3 mb-6">
<div className="absolute -left-[21px] w-3 h-3 rounded-full bg-gray-800 border-2 border-white" />
<h2
className="text-sm font-bold uppercase tracking-widest"
style={{ color: 'var(--theme-primary, #2563eb)' }}
>
Education
</h2>
</div>
<div className="space-y-4">
{education.map(ed => (
<div key={ed.id}>
<div className="flex justify-between items-start">
<div>
<h3 className="text-sm font-bold text-gray-800">{escapeText(ed.degree)}</h3>
<p className="text-xs text-gray-600">{escapeText(ed.school)}</p>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">
{formatDate(ed.startDate)}{ed.endDate ? ` - ${formatDate(ed.endDate)}` : ''}
</span>
</div>
{ed.notes && <div className="mt-1 text-xs text-gray-700">{escapeText(ed.notes)}</div>}
</div>
))}
</div>
</section>
)}
</div>
</div>
</div>
);
};
export default TimelineTemplate;

View File

@ -0,0 +1,57 @@
import ATSTemplate from './ATSTemplate';
import ClassicTemplate from './ClassicTemplate';
import ModernTemplate from './ModernTemplate';
import MinimalTemplate from './MinimalTemplate';
import TimelineTemplate from './TimelineTemplate';
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 timelineThumb from '../assets/templates/timeline.svg';
import type { CV } from '../schema/cvSchema';
import type { ColorTheme } from '../types/colors';
import { generateThumbnailDataUrl, type TemplateId } from '../utils/thumbnailGenerator';
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 }>;
supportsPhoto?: boolean;
}
export const templatesRegistry: TemplateMeta[] = [
{ id: 'ats', name: 'ATS-Friendly', category: 'ATS', thumbnail: atsThumb, component: ATSTemplate, supportsPhoto: false },
{ id: 'classic', name: 'Classic', category: 'Visual', thumbnail: classicThumb, component: ClassicTemplate, supportsPhoto: false },
{ id: 'modern', name: 'Modern', category: 'Visual', thumbnail: modernThumb, component: ModernTemplate, supportsPhoto: true },
{ id: 'minimal', name: 'Minimal', category: 'Visual', thumbnail: minimalThumb, component: MinimalTemplate, supportsPhoto: false },
{ id: 'timeline', name: 'Timeline', category: 'Visual', thumbnail: timelineThumb, component: TimelineTemplate, supportsPhoto: false },
];
export const templatesMap = Object.fromEntries(templatesRegistry.map(t => [t.id, t]));
// Function to get template thumbnail with color theme applied
export const getTemplateThumbnail = (templateId: string, colorTheme: ColorTheme): string => {
const template = templatesMap[templateId];
if (!template) {
return '';
}
// For now, use dynamic generation for supported templates
const supportedTemplates: TemplateId[] = ['ats', 'classic', 'modern', 'minimal', 'timeline'];
if (supportedTemplates.includes(templateId as TemplateId)) {
try {
return generateThumbnailDataUrl(templateId as TemplateId, colorTheme);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${templateId}:`, error);
return template.thumbnail; // Fallback to static thumbnail
}
}
// Fallback to static thumbnail for unsupported templates
return template.thumbnail;
};

View File

@ -0,0 +1,157 @@
export type ColorTheme =
| 'gray'
| 'dark-gray'
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'blue'
| 'indigo'
| 'black'
| null;
export interface ColorPalette {
primary: string; // Main accent color
secondary: string; // Secondary accent color
accent: string; // Light accent/background
text: string; // Primary text color
textSecondary: string; // Secondary text color
border: string; // Border color
background: string; // Background color
}
export const colorThemes: Record<Exclude<ColorTheme, null>, ColorPalette> = {
'gray': {
primary: '#6b7280',
secondary: '#9ca3af',
accent: '#f3f4f6',
text: '#1f2937',
textSecondary: '#6b7280',
border: '#e5e7eb',
background: '#ffffff'
},
'dark-gray': {
primary: '#374151',
secondary: '#6b7280',
accent: '#f9fafb',
text: '#111827',
textSecondary: '#4b5563',
border: '#d1d5db',
background: '#ffffff'
},
'red': {
primary: '#dc2626',
secondary: '#ef4444',
accent: '#fef2f2',
text: '#1f2937',
textSecondary: '#6b7280',
border: '#fecaca',
background: '#ffffff'
},
'orange': {
primary: '#ea580c',
secondary: '#f97316',
accent: '#fff7ed',
text: '#1f2937',
textSecondary: '#6b7280',
border: '#fed7aa',
background: '#ffffff'
},
'yellow': {
primary: '#d97706',
secondary: '#f59e0b',
accent: '#fffbeb',
text: '#1f2937',
textSecondary: '#6b7280',
border: '#fde68a',
background: '#ffffff'
},
'green': {
primary: '#059669',
secondary: '#10b981',
accent: '#f0fdf4',
text: '#1f2937',
textSecondary: '#6b7280',
border: '#bbf7d0',
background: '#ffffff'
},
'blue': {
primary: '#2563eb',
secondary: '#3b82f6',
accent: '#eff6ff',
text: '#1f2937',
textSecondary: '#6b7280',
border: '#bfdbfe',
background: '#ffffff'
},
'indigo': {
primary: '#4f46e5',
secondary: '#6366f1',
accent: '#eef2ff',
text: '#1f2937',
textSecondary: '#6b7280',
border: '#c7d2fe',
background: '#ffffff'
},
'black': {
primary: '#000000',
secondary: '#1f2937',
accent: '#f9fafb',
text: '#000000',
textSecondary: '#374151',
border: '#e5e7eb',
background: '#ffffff'
}
};
export const colorThemeLabels: Record<Exclude<ColorTheme, null>, string> = {
'gray': 'Gray',
'dark-gray': 'Dark Gray',
'red': 'Red',
'orange': 'Orange',
'yellow': 'Yellow',
'green': 'Green',
'blue': 'Blue',
'indigo': 'Indigo',
'black': 'Black'
};
export const getColorThemeLabel = (theme: ColorTheme): string => {
if (theme === null) return 'Default';
return colorThemeLabels[theme];
};
export const colorThemeOrder: ColorTheme[] = [
null, // Default option first
'blue',
'green',
'indigo',
'orange',
'red',
'yellow',
'gray',
'dark-gray',
'black'
];
// Convert color theme to CSS custom properties
export const getThemeCSS = (theme: ColorTheme): React.CSSProperties => {
if (theme === null) {
// Return neutral/original colors for default theme
return {
'--theme-primary': '#1f2937', // gray-800 - neutral dark color
'--theme-secondary': '#374151', // gray-700 - slightly lighter
'--theme-accent': '#f9fafb', // gray-50 - very light background
'--theme-text': '#1f2937', // gray-800 - dark text
'--theme-background': '#ffffff', // white background
} as React.CSSProperties;
}
const colors = colorThemes[theme];
return {
'--theme-primary': colors.primary,
'--theme-secondary': colors.secondary,
'--theme-accent': colors.accent,
'--theme-text': colors.text,
'--theme-background': colors.background,
} as React.CSSProperties;
};

View File

@ -0,0 +1,4 @@
declare module '../../../shared-printable/buildPrintableHtml.js' {
import type { CV } from '../schema/cvSchema';
export function buildPrintableHtml(cv: CV): string;
}

View File

@ -0,0 +1,12 @@
import type { CV } from '../schema/cvSchema';
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, templateId?: string): string => {
const cleaned: CV = {
...cv,
summary: cv.summary ? sanitizeHtml(cv.summary) : ''
};
return sharedBuild(cleaned, templateId);
};

View File

@ -0,0 +1,144 @@
import type { ColorTheme } from '../types/colors';
import { colorThemes } from '../types/colors';
// Base SVG templates for each template type
const baseSVGs = {
ats: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
<!-- Background and frame -->
<rect width="160" height="96" fill="{{background}}" rx="4"/>
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="3"/>
<!-- Header: name and contact -->
<text x="80" y="18" text-anchor="middle" font-family="Times New Roman, serif" font-size="8" font-weight="bold" fill="{{text}}">FIRST LAST</text>
<rect x="20" y="22" width="120" height="3" fill="{{textSecondary}}" rx="1"/>
<!-- Section: PROFESSIONAL EXPERIENCE -->
<text x="16" y="32" font-family="Times New Roman, serif" font-size="4" font-weight="bold" fill="{{text}}">PROFESSIONAL EXPERIENCE</text>
<line x1="16" y1="33" x2="144" y2="33" stroke="{{text}}" stroke-width="0.6"/>
<!-- Job 1 header -->
<rect x="16" y="36" width="86" height="3" fill="{{text}}" rx="1"/>
<rect x="104" y="36" width="40" height="3" fill="{{textSecondary}}" rx="1"/>
<!-- Job 1 title -->
<rect x="16" y="40" width="72" height="3" fill="{{textSecondary}}" rx="1"/>
<!-- Job 1 bullets -->
<circle cx="18" cy="46" r="0.6" fill="{{text}}"/>
<rect x="20" y="45" width="120" height="2" fill="{{textSecondary}}" rx="1"/>
<circle cx="18" cy="49" r="0.6" fill="{{text}}"/>
<rect x="20" y="48" width="116" height="2" fill="{{textSecondary}}" rx="1"/>
<circle cx="18" cy="52" r="0.6" fill="{{text}}"/>
<rect x="20" y="51" width="118" height="2" fill="{{textSecondary}}" rx="1"/>
<!-- Job 2 header -->
<rect x="16" y="56" width="80" height="3" fill="{{text}}" rx="1"/>
<rect x="100" y="56" width="44" height="3" fill="{{textSecondary}}" rx="1"/>
<!-- Job 2 title -->
<rect x="16" y="60" width="68" height="3" fill="{{textSecondary}}" rx="1"/>
<!-- Job 2 bullets -->
<circle cx="18" cy="66" r="0.6" fill="{{text}}"/>
<rect x="20" y="65" width="120" height="2" fill="{{textSecondary}}" rx="1"/>
<!-- Section: EDUCATION -->
<text x="16" y="74" font-family="Times New Roman, serif" font-size="4" font-weight="bold" fill="{{text}}">EDUCATION</text>
<line x1="16" y1="75" x2="144" y2="75" stroke="{{text}}" stroke-width="0.6"/>
<rect x="16" y="78" width="88" height="3" fill="{{text}}" rx="1"/>
<rect x="110" y="78" width="34" height="3" fill="{{textSecondary}}" rx="1"/>
<rect x="16" y="82" width="120" height="2" fill="{{textSecondary}}" rx="1"/>
<!-- Section: SKILLS -->
<text x="16" y="88" font-family="Times New Roman, serif" font-size="4" font-weight="bold" fill="{{text}}">SKILLS</text>
<line x1="16" y1="89" x2="144" y2="89" stroke="{{text}}" stroke-width="0.6"/>
<!-- Skills bullets row -->
<circle cx="18" cy="93" r="0.6" fill="{{text}}"/>
<rect x="20" y="92" width="36" height="2" fill="{{textSecondary}}" rx="1"/>
<circle cx="60" cy="93" r="0.6" fill="{{text}}"/>
<rect x="62" y="92" width="36" height="2" fill="{{textSecondary}}" rx="1"/>
<circle cx="102" cy="93" r="0.6" fill="{{text}}"/>
<rect x="104" y="92" width="36" height="2" fill="{{textSecondary}}" rx="1"/>
</svg>`,
classic: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="96" fill="white" rx="4"/>
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="2"/>
<rect x="16" y="16" width="128" height="10" fill="{{primary}}" rx="1"/>
<rect x="16" y="30" width="96" height="4" fill="{{text}}" rx="1"/>
<rect x="16" y="38" width="80" height="4" fill="{{textSecondary}}" rx="1"/>
<rect x="16" y="50" width="60" height="6" fill="{{secondary}}" rx="1"/>
<rect x="16" y="60" width="120" height="3" fill="{{text}}" rx="1"/>
<rect x="16" y="66" width="112" height="3" fill="{{text}}" rx="1"/>
<rect x="16" y="72" width="104" height="3" fill="{{text}}" rx="1"/>
<text x="80" y="88" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="{{textSecondary}}">Classic</text>
</svg>`,
modern: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="96" fill="white" rx="4"/>
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="2"/>
<rect x="16" y="16" width="40" height="64" fill="{{accent}}" rx="2"/>
<rect x="20" y="20" width="32" height="8" fill="{{primary}}" rx="1"/>
<rect x="20" y="32" width="24" height="4" fill="{{text}}" rx="1"/>
<rect x="20" y="40" width="28" height="4" fill="{{textSecondary}}" rx="1"/>
<rect x="64" y="16" width="80" height="6" fill="{{text}}" rx="1"/>
<rect x="64" y="26" width="72" height="4" fill="{{textSecondary}}" rx="1"/>
<rect x="64" y="34" width="68" height="4" fill="{{textSecondary}}" rx="1"/>
<rect x="64" y="46" width="80" height="6" fill="{{secondary}}" rx="1"/>
<rect x="64" y="56" width="76" height="3" fill="{{text}}" rx="1"/>
<rect x="64" y="62" width="72" height="3" fill="{{text}}" rx="1"/>
<text x="80" y="88" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="{{textSecondary}}">Modern</text>
</svg>`,
minimal: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="96" fill="white" rx="4"/>
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="2"/>
<rect x="16" y="16" width="128" height="8" fill="{{text}}" rx="1"/>
<line x1="16" y1="30" x2="144" y2="30" stroke="{{primary}}" stroke-width="1"/>
<rect x="16" y="36" width="96" height="4" fill="{{textSecondary}}" rx="1"/>
<rect x="16" y="44" width="80" height="4" fill="{{textSecondary}}" rx="1"/>
<line x1="16" y1="54" x2="144" y2="54" stroke="{{border}}" stroke-width="1"/>
<rect x="16" y="60" width="120" height="3" fill="{{text}}" rx="1"/>
<rect x="16" y="66" width="112" height="3" fill="{{text}}" rx="1"/>
<rect x="16" y="72" width="104" height="3" fill="{{text}}" rx="1"/>
<text x="80" y="88" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="{{textSecondary}}">Minimal</text>
</svg>`,
timeline: `<svg width="160" height="96" viewBox="0 0 160 96" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="96" fill="white" rx="4"/>
<rect x="8" y="8" width="144" height="80" fill="none" stroke="{{border}}" stroke-width="1" rx="2"/>
<rect x="16" y="16" width="128" height="8" fill="{{primary}}" rx="1"/>
<line x1="24" y1="32" x2="24" y2="76" stroke="{{secondary}}" stroke-width="2"/>
<circle cx="24" cy="36" r="3" fill="{{primary}}"/>
<rect x="32" y="34" width="80" height="4" fill="{{text}}" rx="1"/>
<rect x="32" y="42" width="64" height="3" fill="{{textSecondary}}" rx="1"/>
<circle cx="24" cy="54" r="3" fill="{{primary}}"/>
<rect x="32" y="52" width="76" height="4" fill="{{text}}" rx="1"/>
<rect x="32" y="60" width="60" height="3" fill="{{textSecondary}}" rx="1"/>
<text x="80" y="88" text-anchor="middle" font-family="Arial, sans-serif" font-size="8" fill="{{textSecondary}}">Timeline</text>
</svg>`
};
export type TemplateId = keyof typeof baseSVGs;
export const generateThumbnail = (templateId: TemplateId, colorTheme: ColorTheme): string => {
const baseSvg = baseSVGs[templateId];
if (!baseSvg) {
throw new Error(`No base SVG found for template: ${templateId}`);
}
// Use default blue theme for null colorTheme
const palette = colorTheme ? colorThemes[colorTheme] : colorThemes.blue;
// Replace color placeholders with actual colors
return baseSvg
.replace(/\{\{primary\}\}/g, palette.primary)
.replace(/\{\{secondary\}\}/g, palette.secondary)
.replace(/\{\{accent\}\}/g, palette.accent)
.replace(/\{\{text\}\}/g, palette.text)
.replace(/\{\{textSecondary\}\}/g, palette.textSecondary)
.replace(/\{\{border\}\}/g, palette.border)
.replace(/\{\{background\}\}/g, palette.background);
};
export const generateThumbnailDataUrl = (templateId: TemplateId, colorTheme: ColorTheme): string => {
const svg = generateThumbnail(templateId, colorTheme);
return `data:image/svg+xml;base64,${btoa(svg)}`;
};

View File

@ -24,5 +24,8 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": [
"src",
"../shared-printable/buildPrintableHtml.d.ts"
]
}

View File

@ -4,4 +4,9 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
fs: {
allow: ['..']
}
}
})

24
cv-export-server/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

2123
cv-export-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
{
"name": "cv-export-server",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "NODE_ENV=development node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"puppeteer": "^24.23.0",
"sanitize-html": "^2.17.0"
}
}

286
cv-export-server/server.js Normal file
View File

@ -0,0 +1,286 @@
import express from 'express';
import cors from 'cors';
import puppeteer from 'puppeteer';
import sanitizeHtml from 'sanitize-html';
import { buildPrintableHtml as sharedBuild } from '../shared-printable/buildPrintableHtml.js';
// Minimal escape function to avoid breaking HTML; summary is sanitized client-side.
function escapeText(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildPrintableHtml(cv) {
const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [] } = cv || {};
const styles = `
<style>
@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; }
</style>
`;
const headerLinks = (personal.links || [])
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
.join(' · ');
const headerHtml = `
<div class="header">
<h1>${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}</h1>
<div class="meta">
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
${headerLinks ? `<span>${headerLinks}</span>` : ''}
</div>
</div>
`;
const cleanSummary = typeof summary === 'string' ? sanitizeHtml(summary, {
allowedTags: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
allowedAttributes: { a: ['href', 'rel', 'target'] }
}) : '';
const summarySection = cleanSummary ? `
<div class="section">
<h2>Professional Summary</h2>
<div>${cleanSummary}</div>
</div>
` : '';
const workSection = (work && work.length) ? `
<div class="section">
<h2>Work Experience</h2>
${work.map(job => `
<div class="job">
<div class="row">
<div>
<h3>${escapeText(job.title)}</h3>
<div class="small">${escapeText(job.company)}</div>
</div>
<div class="small">
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
</div>
</div>
${(job.bullets && job.bullets.length) ? `
<ul>
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
</ul>
` : ''}
</div>
`).join('')}
</div>
` : '';
const eduSection = (education && education.length) ? `
<div class="section">
<h2>Education</h2>
${education.map(ed => `
<div class="edu">
<div class="row">
<div>
<h3>${escapeText(ed.degree)}</h3>
<div class="small">${escapeText(ed.school)}</div>
</div>
<div class="small">
${escapeText(ed.startDate || '')} - ${escapeText(ed.endDate || '')}
</div>
</div>
${ed.gpa ? `<div class="small">GPA: ${escapeText(ed.gpa)}</div>` : ''}
</div>
`).join('')}
</div>
` : '';
const skillsSection = (skills && skills.length) ? `
<div class="section">
<h2>Skills</h2>
<div class="chips">
${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? `${escapeText(s.level)}` : ''}</span>`).join('')}
</div>
</div>
` : '';
const languagesSection = (languages && languages.length) ? `
<div class="section">
<h2>Languages</h2>
<div class="chips">
${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? `${escapeText(l.level)}` : ''}</span>`).join('')}
</div>
</div>
` : '';
const certsSection = (certifications && certifications.length) ? `
<div class="section">
<h2>Certifications</h2>
<ul>
${certifications.map(c => `<li>${escapeText(c.name)}${c.issuer ? ` - ${escapeText(c.issuer)}` : ''}${c.date ? ` (${escapeText(c.date)})` : ''}</li>`).join('')}
</ul>
</div>
` : '';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Printable CV</title>
${styles}
</head>
<body>
<div class="page">
${headerHtml}
${summarySection}
${workSection}
${eduSection}
${skillsSection}
${languagesSection}
${certsSection}
</div>
</body>
</html>`;
}
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
// Pluggable job store with in-memory default
class InMemoryJobStore {
constructor() {
this.map = new Map();
}
create(job) {
const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
this.map.set(id, { ...job, id });
return id;
}
get(id) { return this.map.get(id); }
set(id, data) { this.map.set(id, { ...(this.map.get(id) || {}), ...data }); }
cancel(id) {
const job = this.map.get(id);
if (!job) return false;
this.map.set(id, { ...job, status: 'canceled', error: null, buffer: null });
return true;
}
}
const jobs = new InMemoryJobStore();
app.post('/export/pdf', async (req, res) => {
try {
// Treat presence of the query param as async, and allow common truthy values
const asyncFlag = (
typeof req.query.async !== 'undefined'
? ['1', 'true', 'on', ''].includes(String(req.query.async).toLowerCase())
: req.body?.async === true
);
const templateId = req.query.templateId || req.body?.templateId || null;
const cv = req.body || {};
const sanitizedCv = {
...cv,
summary: typeof cv.summary === 'string' ? sanitizeHtml(cv.summary, {
allowedTags: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
allowedAttributes: { a: ['href', 'rel', 'target'] }
}) : ''
};
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, '0');
const d = String(today.getDate()).padStart(2, '0');
const dateStr = `${y}${m}${d}`;
const rawLast = (sanitizedCv.personal && sanitizedCv.personal.lastName) ? sanitizedCv.personal.lastName : '';
const safeLast = rawLast.trim() ? rawLast.trim() : 'cv';
const filename = `cv-${safeLast}-${dateStr}.pdf`.replace(/\s+/g, '_');
if (asyncFlag) {
const jobId = jobs.create({ status: 'queued', filename, error: null, buffer: null, templateId });
setImmediate(async () => {
try {
const current = jobs.get(jobId);
if (current?.status === 'canceled') return; // honor cancel
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' });
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
jobs.set(jobId, { status: 'done', filename, error: null, buffer: pdfBuffer, templateId });
} catch (e) {
console.error('Async export error:', e);
jobs.set(jobId, { status: 'error', filename, error: 'Failed to create PDF', buffer: null, templateId });
}
});
return res.json({ jobId });
}
// Synchronous path
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' });
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`
});
res.send(pdfBuffer);
} catch (err) {
console.error('Export error:', err);
res.status(500).json({ error: 'Failed to create PDF' });
}
});
// Job status endpoint
app.get('/export/status/:id', (req, res) => {
const job = jobs.get(req.params.id);
if (!job) return res.status(404).json({ error: 'Job not found' });
res.json({ status: job.status, filename: job.filename, error: job.error });
});
// Job download endpoint
app.get('/export/download/:id', (req, res) => {
const job = jobs.get(req.params.id);
if (!job) return res.status(404).json({ error: 'Job not found' });
if (job.status !== 'done' || !job.buffer) return res.status(409).json({ error: 'Job not ready' });
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${job.filename}"`
});
res.send(job.buffer);
});
// Cancel job endpoint
app.post('/export/cancel/:id', (req, res) => {
const id = req.params.id;
const ok = jobs.cancel(id);
if (!ok) return res.status(404).json({ error: 'Job not found' });
return res.json({ status: 'canceled' });
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`Export server listening on http://localhost:${PORT}`);
});

11
headers.txt Normal file
View File

@ -0,0 +1,11 @@
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/pdf
Content-Disposition: attachment; filename="Ada_Lovelace_CV.pdf"
Content-Length: 13298
ETag: W/"33f2-1gLBdxasSpROFsvw/XUGR63Xjhg"
Date: Sun, 05 Oct 2025 23:50:44 GMT
Connection: keep-alive
Keep-Alive: timeout=5

BIN
job.json Normal file

Binary file not shown.

1
out.json Normal file
View File

@ -0,0 +1 @@
{"jobId":"job_1759708234671_izajbw"}

BIN
out.pdf Normal file

Binary file not shown.

View File

@ -0,0 +1,2 @@
export type CV = import('../cv-engine/src/schema/cvSchema').CV;
export function buildPrintableHtml(cv: CV, templateId?: string): string;

View File

@ -0,0 +1,307 @@
// ESM module: shared printable HTML builder
// Note: callers must sanitize any HTML fields (e.g., summary) before passing.
function escapeText(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 timelineStyles = `
@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; }
.grid { display: grid; grid-template-columns: 1fr 2fr; gap: 18px; }
.sidebar h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 1.2px; color: #111827; margin: 0; }
.divider { border-top: 1px solid #e5e7eb; margin: 4px 0 8px; }
.contact-list { font-size: 12px; color: #111827; }
.timeline { position: relative; }
.tl-row { display: grid; grid-template-columns: 24px 1fr; gap: 12px; margin-bottom: 16px; }
.tl-dot { width: 10px; height: 10px; background: #2563eb; border-radius: 50%; margin-top: 3px; }
.tl-line { position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; background: #e5e7eb; }
.small { font-size: 12px; color: #6b7280; }
`;
const stylesVariant = tid === 'classic' ? classicStyles : tid === 'modern' ? modernStyles : tid === 'minimal' ? minimalStyles : tid === 'timeline' ? timelineStyles : '';
const styles = `
<style>
${baseStyles}
${stylesVariant}
</style>
`;
const headerLinks = (personal.links || [])
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
.join(' · ');
const safePhotoUrl = typeof personal.photoUrl === 'string' && /^(https?:\/\/)/i.test(personal.photoUrl) ? personal.photoUrl : '';
const headerPhoto = tid === 'modern' && safePhotoUrl ? `<img src="${escapeText(safePhotoUrl)}" alt="${escapeText((personal.firstName || '') + ' ' + (personal.lastName || ''))} photo" style="width:64px;height:64px;border-radius:50%;object-fit:cover;border:2px solid rgba(255,255,255,0.7);margin-right:12px;" referrerpolicy="no-referrer" />` : '';
const headline = Array.isArray(personal.extras) && personal.extras.length ? personal.extras[0] : '';
const headerHtml = tid === 'timeline'
? `
<div class="header">
<h1>${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}</h1>
${headline ? `<div class="small" style="text-transform:uppercase;margin-top:4px;color:#374151;">${escapeText(headline)}</div>` : ''}
</div>
`
: `
<div class="header" style="display:flex;align-items:center;gap:12px;">
${headerPhoto}
<div>
<h1>${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}</h1>
<div class="meta">
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
${headerLinks ? `<span>${headerLinks}</span>` : ''}
</div>
</div>
</div>
`;
const summarySection = summary ? `
<div class="section">
<h2>Professional Summary</h2>
<div>${summary}</div>
</div>
` : '';
const workSection = (work && work.length) ? (
tid === 'timeline'
? `
<div class="section">
<h2>Work Experience</h2>
<div class="timeline">
<div class="tl-line"></div>
${work.map(job => `
<div class="tl-row">
<div class="tl-dot"></div>
<div>
<div class="row" style="justify-content:space-between;">
<div>
<h3>${escapeText(job.title)}</h3>
<div class="small">${escapeText(job.company)}</div>
</div>
<div class="small">
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
</div>
</div>
${(job.bullets && job.bullets.length) ? `
<ul>
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
</ul>
` : ''}
</div>
</div>
`).join('')}
</div>
</div>
`
: `
<div class="section">
<h2>Work Experience</h2>
${work.map(job => `
<div class="job">
<div class="row">
<div>
<h3>${escapeText(job.title)}</h3>
<div class="small">${escapeText(job.company)}</div>
</div>
<div class="small">
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
</div>
</div>
${(job.bullets && job.bullets.length) ? `
<ul>
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
</ul>
` : ''}
</div>
`).join('')}
</div>
`
) : '';
const eduSection = (education && education.length) ? `
<div class="section">
<h2>Education</h2>
${education.map(ed => `
<div class="edu">
<div class="row">
<div>
<h3>${escapeText(ed.degree)}</h3>
<div class="small">${escapeText(ed.school)}</div>
</div>
<div class="small">
${escapeText(ed.startDate || '')} - ${escapeText(ed.endDate || '')}
</div>
</div>
${ed.gpa ? `<div class="small">GPA: ${escapeText(ed.gpa)}</div>` : ''}
</div>
`).join('')}
</div>
` : '';
const skillsSection = (skills && skills.length) ? `
<div class="section">
<h2>Skills</h2>
<div class="chips">
${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? `${escapeText(s.level)}` : ''}</span>`).join('')}
</div>
</div>
` : '';
const languagesSection = (languages && languages.length) ? `
<div class="section">
<h2>Languages</h2>
<div class="chips">
${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? `${escapeText(l.level)}` : ''}</span>`).join('')}
</div>
</div>
` : '';
const certsSection = (certifications && certifications.length) ? `
<div class="section">
<h2>Certifications</h2>
<ul>
${certifications.map(c => `<li>${escapeText(c.name)}${c.issuer ? ` - ${escapeText(c.issuer)}` : ''}${c.date ? ` (${escapeText(c.date)})` : ''}</li>`).join('')}
</ul>
</div>
` : '';
const accentTop = tid === 'modern' ? '<div class="accent"></div>' : '';
// Sidebar for timeline theme: contact + skills/languages + references
const referenceLines = (Array.isArray(personal.extras) ? personal.extras.slice(1) : []).filter(Boolean);
const sidebar = tid === 'timeline' ? `
<aside class="sidebar">
<section>
<h2>Contact</h2>
<div class="divider"></div>
<div class="contact-list">
${personal.phone ? `<div>${escapeText(personal.phone)}</div>` : ''}
${personal.email ? `<div>${escapeText(personal.email)}</div>` : ''}
${(personal.street || personal.city || personal.country || personal.postcode) ? `<div>${escapeText([personal.street, personal.city, personal.country, personal.postcode].filter(Boolean).join(', '))}</div>` : ''}
${headerLinks ? `<div>${headerLinks}</div>` : ''}
</div>
</section>
${(skills && skills.length) ? `
<section>
<h2>Skills</h2>
<div class="divider"></div>
<div class="chips">${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? `${escapeText(s.level)}` : ''}</span>`).join('')}</div>
</section>
` : ''}
${(languages && languages.length) ? `
<section>
<h2>Languages</h2>
<div class="divider"></div>
<div class="chips">${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? `${escapeText(l.level)}` : ''}</span>`).join('')}</div>
</section>
` : ''}
${(referenceLines && referenceLines.length) ? `
<section>
<h2>Reference</h2>
<div class="divider"></div>
<div class="small">${referenceLines.map(r => `<div>${escapeText(r)}</div>`).join('')}</div>
</section>
` : ''}
</aside>
` : '';
const bodyContent = tid === 'timeline'
? `
${accentTop}
${headerHtml}
<div class="grid">
${sidebar}
<main>
${summarySection}
${workSection}
${eduSection}
${certsSection}
</main>
</div>
`
: `
${accentTop}
${headerHtml}
${summarySection}
${workSection}
${eduSection}
${skillsSection}
${languagesSection}
${certsSection}
`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Printable CV</title>
${styles}
</head>
<body>
<div class="page">
${bodyContent}
</div>
</body>
</html>`;
}