Compare commits
10 Commits
46e1f245f8
...
a836a7a1d6
| Author | SHA1 | Date | |
|---|---|---|---|
| a836a7a1d6 | |||
| 3a9591fb48 | |||
| 81712044c2 | |||
| 2847cef81b | |||
| d35700bc10 | |||
| 44fdc1ce52 | |||
| 0ebf5fe3de | |||
| adeb8473b7 | |||
| 3468cf7a43 | |||
| 48b48f8165 |
155
README.md
155
README.md
@ -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 template’s 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 don’t 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.
|
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
|
- At least two templates with instant switching by M6
|
||||||
- Accessibility AA and test coverage by M7
|
- 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
|
## Glossary
|
||||||
- ATS: Applicant Tracking System; favors semantic, minimal styling.
|
- ATS: Applicant Tracking System; favors semantic, minimal styling.
|
||||||
- Parity: Inline preview and exported PDF display equivalent content/layout.
|
- Parity: Inline preview and exported PDF display equivalent content/layout.
|
||||||
|
|||||||
294
cv-engine/package-lock.json
generated
294
cv-engine/package-lock.json
generated
@ -9,9 +9,11 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tiptap/extension-placeholder": "^3.6.5",
|
||||||
"@tiptap/react": "^3.6.5",
|
"@tiptap/react": "^3.6.5",
|
||||||
"@tiptap/starter-kit": "^3.6.5",
|
"@tiptap/starter-kit": "^3.6.5",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"dompurify": "^3.2.7",
|
"dompurify": "^3.2.7",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@ -2022,6 +2024,19 @@
|
|||||||
"@tiptap/core": "^3.6.5"
|
"@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": {
|
"node_modules/@tiptap/extension-strike": {
|
||||||
"version": "3.6.5",
|
"version": "3.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.6.5.tgz",
|
"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==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"license": "Python-2.0"
|
"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": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.21",
|
"version": "10.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||||
@ -2681,6 +2702,17 @@
|
|||||||
"postcss": "^8.1.0"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"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": "^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": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@ -2831,6 +2876,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@ -2897,6 +2954,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@ -2916,6 +2982,20 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.230",
|
"version": "1.5.230",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.230.tgz",
|
"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"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.10",
|
"version": "0.25.10",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||||
@ -3313,6 +3438,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"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": "^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": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@ -3351,6 +3521,43 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@ -3377,6 +3584,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@ -3401,6 +3620,45 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@ -3893,6 +4151,15 @@
|
|||||||
"markdown-it": "bin/markdown-it.mjs"
|
"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": {
|
"node_modules/mdurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
@ -3923,6 +4190,27 @@
|
|||||||
"node": ">=8.6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@ -4353,6 +4641,12 @@
|
|||||||
"prosemirror-transform": "^1.1.0"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
@ -11,9 +11,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tiptap/extension-placeholder": "^3.6.5",
|
||||||
"@tiptap/react": "^3.6.5",
|
"@tiptap/react": "^3.6.5",
|
||||||
"@tiptap/starter-kit": "^3.6.5",
|
"@tiptap/starter-kit": "^3.6.5",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"dompurify": "^3.2.7",
|
"dompurify": "^3.2.7",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|||||||
@ -1,18 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Stepper from './components/Stepper';
|
import Stepper from './components/Stepper';
|
||||||
|
import TemplateSelectionEditor from './editors/TemplateSelectionEditor';
|
||||||
import PersonalEditor from './editors/PersonalEditor';
|
import PersonalEditor from './editors/PersonalEditor';
|
||||||
import WorkEditor from './editors/WorkEditor';
|
import WorkEditor from './editors/WorkEditor';
|
||||||
import EducationEditor from './editors/EducationEditor';
|
import EducationEditor from './editors/EducationEditor';
|
||||||
import SkillsEditor from './editors/SkillsEditor';
|
import SkillsEditor from './editors/SkillsEditor';
|
||||||
|
import SummaryEditor from './editors/SummaryEditor';
|
||||||
import PreviewPanel from './components/PreviewPanel';
|
import PreviewPanel from './components/PreviewPanel';
|
||||||
import { useActiveStep } from './store/cvStore';
|
import { useActiveStep } from './store/cvStore';
|
||||||
|
import { useLocalAutosave } from './hooks/useLocalAutosave';
|
||||||
|
import AutosaveStatus from './components/AutosaveStatus';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const activeStep = useActiveStep();
|
const activeStep = useActiveStep();
|
||||||
|
useLocalAutosave('cv-engine:draft', 800);
|
||||||
|
|
||||||
// Render the appropriate editor based on the active step
|
// Render the appropriate editor based on the active step
|
||||||
const renderEditor = () => {
|
const renderEditor = () => {
|
||||||
switch (activeStep) {
|
switch (activeStep) {
|
||||||
|
case 'template':
|
||||||
|
return <TemplateSelectionEditor />;
|
||||||
case 'personal':
|
case 'personal':
|
||||||
return <PersonalEditor />;
|
return <PersonalEditor />;
|
||||||
case 'work':
|
case 'work':
|
||||||
@ -22,11 +29,11 @@ const App: React.FC = () => {
|
|||||||
case 'skills':
|
case 'skills':
|
||||||
return <SkillsEditor />;
|
return <SkillsEditor />;
|
||||||
case 'summary':
|
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':
|
case 'finalize':
|
||||||
return <div className="p-6 bg-white rounded-lg shadow-sm">Finalize step will be implemented in M5</div>;
|
return <div className="p-6 bg-white rounded-lg shadow-sm">Finalize step will be implemented in M5</div>;
|
||||||
default:
|
default:
|
||||||
return <PersonalEditor />;
|
return <TemplateSelectionEditor />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -34,13 +41,23 @@ const App: React.FC = () => {
|
|||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<header className="bg-white shadow-sm">
|
<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="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>
|
<h1 className="text-2xl font-bold text-gray-900">CV Engine</h1>
|
||||||
|
<AutosaveStatus />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
<main className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<Stepper className="mb-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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Editor Panel */}
|
{/* Editor Panel */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
@ -52,6 +69,7 @@ const App: React.FC = () => {
|
|||||||
<PreviewPanel className="h-full" />
|
<PreviewPanel className="h-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="bg-white mt-12 py-6 border-t">
|
<footer className="bg-white mt-12 py-6 border-t">
|
||||||
|
|||||||
81
cv-engine/src/api/export.ts
Normal file
81
cv-engine/src/api/export.ts
Normal 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}`);
|
||||||
|
}
|
||||||
46
cv-engine/src/assets/templates/ats.svg
Normal file
46
cv-engine/src/assets/templates/ats.svg
Normal 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 |
10
cv-engine/src/assets/templates/classic.svg
Normal file
10
cv-engine/src/assets/templates/classic.svg
Normal 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 |
9
cv-engine/src/assets/templates/minimal.svg
Normal file
9
cv-engine/src/assets/templates/minimal.svg
Normal 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 |
11
cv-engine/src/assets/templates/modern.svg
Normal file
11
cv-engine/src/assets/templates/modern.svg
Normal 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 |
148
cv-engine/src/assets/templates/timeline.svg
Normal file
148
cv-engine/src/assets/templates/timeline.svg
Normal 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 & 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 & 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 & 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 |
35
cv-engine/src/assets/templates/timeline_old.svg
Normal file
35
cv-engine/src/assets/templates/timeline_old.svg
Normal 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 |
28
cv-engine/src/components/AutosaveStatus.tsx
Normal file
28
cv-engine/src/components/AutosaveStatus.tsx
Normal 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;
|
||||||
151
cv-engine/src/components/ExportControls.tsx
Normal file
151
cv-engine/src/components/ExportControls.tsx
Normal 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;
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useCvStore } from '../store/cvStore';
|
import { useCvStore, useColorTheme } from '../store/cvStore';
|
||||||
import ATSTemplate from '../templates/ATSTemplate';
|
import { templatesMap } from '../templates/registry';
|
||||||
|
import { buildPrintableHtml } from '../utils/printable';
|
||||||
|
import ExportControls from './ExportControls';
|
||||||
|
import TemplateGallery from './TemplateGallery';
|
||||||
|
import ThemeProvider from './ThemeProvider';
|
||||||
|
|
||||||
interface PreviewPanelProps {
|
interface PreviewPanelProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -9,20 +13,62 @@ interface PreviewPanelProps {
|
|||||||
const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||||
const cv = useCvStore(state => state.cv);
|
const cv = useCvStore(state => state.cv);
|
||||||
const templateId = useCvStore(state => state.cv.templateId);
|
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 (
|
return (
|
||||||
<div className={`bg-gray-100 rounded-lg shadow-inner overflow-auto ${className}`}>
|
<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">
|
<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>
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4 space-y-4">
|
||||||
{/* Render the appropriate template based on templateId */}
|
<TemplateGallery />
|
||||||
{templateId === 'ats' && <ATSTemplate cv={cv} />}
|
{mode === 'inline' && (
|
||||||
{/* Add more template options here as they are implemented */}
|
<>
|
||||||
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const Stepper: React.FC<StepperProps> = ({ className = '' }) => {
|
|||||||
const { setActiveStep, validateActiveSection, nextStep, prevStep } = useCvStore();
|
const { setActiveStep, validateActiveSection, nextStep, prevStep } = useCvStore();
|
||||||
|
|
||||||
const steps: { id: EditorStep; label: string }[] = [
|
const steps: { id: EditorStep; label: string }[] = [
|
||||||
|
{ id: 'template', label: 'Choose Template' },
|
||||||
{ id: 'personal', label: 'Personal Details' },
|
{ id: 'personal', label: 'Personal Details' },
|
||||||
{ id: 'work', label: 'Work Experience' },
|
{ id: 'work', label: 'Work Experience' },
|
||||||
{ id: 'education', label: 'Education' },
|
{ id: 'education', label: 'Education' },
|
||||||
@ -86,10 +87,10 @@ const Stepper: React.FC<StepperProps> = ({ className = '' }) => {
|
|||||||
<div className="flex justify-between mt-8">
|
<div className="flex justify-between mt-8">
|
||||||
<button
|
<button
|
||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
disabled={activeStep === 'personal'}
|
disabled={activeStep === 'template'}
|
||||||
className={`
|
className={`
|
||||||
px-4 py-2 rounded-md text-sm font-medium
|
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
|
Previous
|
||||||
|
|||||||
58
cv-engine/src/components/TemplateGallery.tsx
Normal file
58
cv-engine/src/components/TemplateGallery.tsx
Normal 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;
|
||||||
24
cv-engine/src/components/ThemeProvider.tsx
Normal file
24
cv-engine/src/components/ThemeProvider.tsx
Normal 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;
|
||||||
@ -1,10 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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';
|
import { normalizeUrl } from '../schema/cvSchema';
|
||||||
|
|
||||||
const PersonalEditor: React.FC = () => {
|
const PersonalEditor: React.FC = () => {
|
||||||
const personalData = usePersonalData();
|
const personalData = usePersonalData();
|
||||||
const { updatePersonal } = useCvStore();
|
const { updatePersonal } = useCvStore();
|
||||||
|
const templateId = useTemplateId();
|
||||||
|
const supportsPhoto = !!templatesMap[templateId]?.supportsPhoto;
|
||||||
const [formData, setFormData] = useState(personalData);
|
const [formData, setFormData] = useState(personalData);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@ -40,6 +43,14 @@ const PersonalEditor: React.FC = () => {
|
|||||||
} else if (!emailRegex.test(value)) {
|
} else if (!emailRegex.test(value)) {
|
||||||
setErrors(prev => ({ ...prev, email: 'Invalid email format' }));
|
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
|
// Update store on blur if no errors
|
||||||
@ -94,6 +105,38 @@ const PersonalEditor: React.FC = () => {
|
|||||||
updatePersonal(updatedFormData);
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@ -214,6 +257,29 @@ const PersonalEditor: React.FC = () => {
|
|||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Address Fields */}
|
{/* Address Fields */}
|
||||||
@ -339,6 +405,63 @@ const PersonalEditor: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,10 +3,19 @@ import { useSkillsData, useCvStore } from '../store/cvStore';
|
|||||||
|
|
||||||
const SkillsEditor: React.FC = () => {
|
const SkillsEditor: React.FC = () => {
|
||||||
const skills = useSkillsData();
|
const skills = useSkillsData();
|
||||||
const { addSkill, updateSkill, removeSkill } = useCvStore();
|
const { addSkill, updateSkill, removeSkill, reorderSkills } = useCvStore();
|
||||||
const [newSkillName, setNewSkillName] = useState('');
|
const [newSkillName, setNewSkillName] = useState('');
|
||||||
const [newSkillLevel, setNewSkillLevel] = useState<'Beginner' | 'Intermediate' | 'Advanced' | undefined>(undefined);
|
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 handleAddSkill = () => {
|
||||||
const name = newSkillName.trim();
|
const name = newSkillName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@ -34,7 +43,7 @@ const SkillsEditor: React.FC = () => {
|
|||||||
<label className="block text-sm font-medium text-gray-700">Level (optional)</label>
|
<label className="block text-sm font-medium text-gray-700">Level (optional)</label>
|
||||||
<select
|
<select
|
||||||
value={newSkillLevel || ''}
|
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"
|
className="px-3 py-2 border rounded-md border-gray-300"
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
@ -57,7 +66,7 @@ const SkillsEditor: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{skills.map((skill) => (
|
{skills.map((skill, idx) => (
|
||||||
<div key={skill.id} className="flex items-center gap-2">
|
<div key={skill.id} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -67,7 +76,7 @@ const SkillsEditor: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={skill.level || ''}
|
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"
|
className="px-3 py-2 border rounded-md border-gray-300"
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
@ -75,6 +84,26 @@ const SkillsEditor: React.FC = () => {
|
|||||||
<option value="Intermediate">Intermediate</option>
|
<option value="Intermediate">Intermediate</option>
|
||||||
<option value="Advanced">Advanced</option>
|
<option value="Advanced">Advanced</option>
|
||||||
</select>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeSkill(skill.id)}
|
onClick={() => removeSkill(skill.id)}
|
||||||
|
|||||||
125
cv-engine/src/editors/SummaryEditor.tsx
Normal file
125
cv-engine/src/editors/SummaryEditor.tsx
Normal 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;
|
||||||
167
cv-engine/src/editors/TemplateSelectionEditor.tsx
Normal file
167
cv-engine/src/editors/TemplateSelectionEditor.tsx
Normal 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;
|
||||||
66
cv-engine/src/hooks/useLocalAutosave.ts
Normal file
66
cv-engine/src/hooks/useLocalAutosave.ts
Normal 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]);
|
||||||
|
}
|
||||||
@ -1,4 +1,20 @@
|
|||||||
import { z } from 'zod';
|
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
|
// Helper function to generate unique IDs
|
||||||
export const generateId = () => Math.random().toString(36).substring(2, 9);
|
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"),
|
lastName: z.string().min(1, "Last name is required"),
|
||||||
email: z.string().email("Invalid email address"),
|
email: z.string().email("Invalid email address"),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
|
photoUrl: z.string().optional().default(""),
|
||||||
street: z.string().optional(),
|
street: z.string().optional(),
|
||||||
city: z.string().optional(),
|
city: z.string().optional(),
|
||||||
country: z.string().optional(),
|
country: z.string().optional(),
|
||||||
@ -68,7 +85,10 @@ export const CvSchema = z.object({
|
|||||||
date: z.string().optional()
|
date: z.string().optional()
|
||||||
})
|
})
|
||||||
).optional().default([]),
|
).optional().default([]),
|
||||||
|
// Optional ProseMirror JSON for summary rich content persistence
|
||||||
|
summaryJson: z.any().optional(),
|
||||||
templateId: z.string().default('ats'),
|
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
|
// Type inference from the schema
|
||||||
@ -81,6 +101,7 @@ export const createEmptyCV = (): CV => ({
|
|||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
|
photoUrl: "",
|
||||||
street: "",
|
street: "",
|
||||||
city: "",
|
city: "",
|
||||||
country: "",
|
country: "",
|
||||||
@ -94,7 +115,9 @@ export const createEmptyCV = (): CV => ({
|
|||||||
skills: [],
|
skills: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
certifications: [],
|
certifications: [],
|
||||||
|
summaryJson: undefined,
|
||||||
templateId: "ats",
|
templateId: "ats",
|
||||||
|
colorTheme: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper functions for validation
|
// Helper functions for validation
|
||||||
@ -109,12 +132,12 @@ export const validateSection = <K extends keyof CV>(section: K, data: CV[K]) =>
|
|||||||
|
|
||||||
// Sanitization helpers
|
// Sanitization helpers
|
||||||
export const sanitizeHtml = (html: string): string => {
|
export const sanitizeHtml = (html: string): string => {
|
||||||
// In a real implementation, we would use DOMPurify here
|
// Sanitize summary while preserving basic formatting elements
|
||||||
// This is a simple placeholder
|
return DOMPurify.sanitize(html, {
|
||||||
return html
|
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
|
||||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
ALLOWED_ATTR: ['href', 'rel', 'target'],
|
||||||
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
|
ALLOW_UNKNOWN_PROTOCOLS: false
|
||||||
.replace(/<[^>]*>/g, ''); // Remove all HTML tags for now
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const escapeText = (text: string): string => {
|
export const escapeText = (text: string): string => {
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { CvSchema, createEmptyCV, validateSection } from '../schema/cvSchema';
|
import { CvSchema, createEmptyCV, validateSection } from '../schema/cvSchema';
|
||||||
|
import type { ColorTheme } from '../types/colors';
|
||||||
|
|
||||||
// Define the steps for the CV editor
|
// 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
|
// Define the store state
|
||||||
interface CvState {
|
interface CvState {
|
||||||
@ -13,16 +14,21 @@ interface CvState {
|
|||||||
activeStep: EditorStep;
|
activeStep: EditorStep;
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
lastSaved: Date | null;
|
lastSaved: Date | null;
|
||||||
|
saveStatus: 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
saveError: string | null;
|
||||||
errors: Record<string, string[]>;
|
errors: Record<string, string[]>;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setCv: (cv: z.infer<typeof CvSchema>) => void;
|
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;
|
updatePersonal: (personal: z.infer<typeof CvSchema>['personal']) => void;
|
||||||
updateWork: (work: z.infer<typeof CvSchema>['work']) => void;
|
updateWork: (work: z.infer<typeof CvSchema>['work']) => void;
|
||||||
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
|
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
|
||||||
updateSkills: (skills: z.infer<typeof CvSchema>['skills']) => void;
|
updateSkills: (skills: z.infer<typeof CvSchema>['skills']) => void;
|
||||||
updateSummary: (summary: string) => void;
|
updateSummary: (summary: string) => void;
|
||||||
|
updateSummaryJson: (json: unknown) => void;
|
||||||
updateTemplateId: (templateId: string) => void;
|
updateTemplateId: (templateId: string) => void;
|
||||||
|
updateColorTheme: (colorTheme: ColorTheme) => void;
|
||||||
|
|
||||||
// Work item operations
|
// Work item operations
|
||||||
addWorkItem: () => void;
|
addWorkItem: () => void;
|
||||||
@ -40,6 +46,7 @@ interface CvState {
|
|||||||
addSkill: (name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
|
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;
|
updateSkill: (id: string, name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
|
||||||
removeSkill: (id: string) => void;
|
removeSkill: (id: string) => void;
|
||||||
|
reorderSkills: (fromIndex: number, toIndex: number) => void;
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
setActiveStep: (step: EditorStep) => void;
|
setActiveStep: (step: EditorStep) => void;
|
||||||
@ -49,6 +56,9 @@ interface CvState {
|
|||||||
// State management
|
// State management
|
||||||
setDirty: (isDirty: boolean) => void;
|
setDirty: (isDirty: boolean) => void;
|
||||||
setLastSaved: (date: Date | null) => void;
|
setLastSaved: (date: Date | null) => void;
|
||||||
|
setSaveStatus: (status: CvState['saveStatus']) => void;
|
||||||
|
setSaveError: (error: string | null) => void;
|
||||||
|
markSaved: (date?: Date) => void;
|
||||||
validateActiveSection: () => boolean;
|
validateActiveSection: () => boolean;
|
||||||
resetErrors: () => void;
|
resetErrors: () => void;
|
||||||
}
|
}
|
||||||
@ -57,14 +67,18 @@ interface CvState {
|
|||||||
export const useCvStore = create<CvState>((set, get) => ({
|
export const useCvStore = create<CvState>((set, get) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
cv: createEmptyCV(),
|
cv: createEmptyCV(),
|
||||||
activeStep: 'personal',
|
activeStep: 'template',
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
lastSaved: null,
|
lastSaved: null,
|
||||||
|
saveStatus: 'idle',
|
||||||
|
saveError: null,
|
||||||
errors: {},
|
errors: {},
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setCv: (cv) => set({ cv, isDirty: true }),
|
setCv: (cv) => set({ cv, isDirty: true }),
|
||||||
|
|
||||||
|
hydrate: (cv, lastSaved) => set({ cv, isDirty: false, lastSaved: lastSaved ?? null }),
|
||||||
|
|
||||||
updatePersonal: (personal) => {
|
updatePersonal: (personal) => {
|
||||||
const result = validateSection('personal', personal);
|
const result = validateSection('personal', personal);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@ -111,13 +125,27 @@ export const useCvStore = create<CvState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTemplateId: (templateId) => {
|
updateSummaryJson: (json) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
cv: { ...state.cv, summaryJson: json },
|
||||||
|
isDirty: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTemplateId: (templateId) => {
|
||||||
|
set(state => ({
|
||||||
cv: { ...state.cv, templateId },
|
cv: { ...state.cv, templateId },
|
||||||
isDirty: true
|
isDirty: true
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateColorTheme: (colorTheme) => {
|
||||||
|
set(state => ({
|
||||||
|
cv: { ...state.cv, colorTheme },
|
||||||
|
isDirty: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
// Work item operations
|
// Work item operations
|
||||||
addWorkItem: () => {
|
addWorkItem: () => {
|
||||||
const { cv } = get();
|
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
|
// Navigation
|
||||||
setActiveStep: (activeStep) => set({ activeStep }),
|
setActiveStep: (activeStep) => set({ activeStep }),
|
||||||
|
|
||||||
nextStep: () => {
|
nextStep: () => {
|
||||||
const { activeStep } = get();
|
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);
|
const currentIndex = steps.indexOf(activeStep);
|
||||||
if (currentIndex < steps.length - 1) {
|
if (currentIndex < steps.length - 1) {
|
||||||
set({ activeStep: steps[currentIndex + 1] });
|
set({ activeStep: steps[currentIndex + 1] });
|
||||||
@ -256,7 +295,7 @@ export const useCvStore = create<CvState>((set, get) => ({
|
|||||||
|
|
||||||
prevStep: () => {
|
prevStep: () => {
|
||||||
const { activeStep } = get();
|
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);
|
const currentIndex = steps.indexOf(activeStep);
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
set({ activeStep: steps[currentIndex - 1] });
|
set({ activeStep: steps[currentIndex - 1] });
|
||||||
@ -266,6 +305,9 @@ export const useCvStore = create<CvState>((set, get) => ({
|
|||||||
// State management
|
// State management
|
||||||
setDirty: (isDirty) => set({ isDirty }),
|
setDirty: (isDirty) => set({ isDirty }),
|
||||||
setLastSaved: (lastSaved) => set({ lastSaved }),
|
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: () => {
|
validateActiveSection: () => {
|
||||||
const { activeStep, cv } = get();
|
const { activeStep, cv } = get();
|
||||||
@ -273,6 +315,20 @@ export const useCvStore = create<CvState>((set, get) => ({
|
|||||||
// Map step to CV section
|
// Map step to CV section
|
||||||
let isValid = true;
|
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') {
|
if (activeStep === 'personal') {
|
||||||
const result = validateSection('personal', cv.personal);
|
const result = validateSection('personal', cv.personal);
|
||||||
isValid = result.success;
|
isValid = result.success;
|
||||||
@ -327,6 +383,20 @@ export const useCvStore = create<CvState>((set, get) => ({
|
|||||||
set((state) => ({ errors: { ...state.errors, skills: [] } }));
|
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;
|
return isValid;
|
||||||
},
|
},
|
||||||
@ -340,7 +410,11 @@ export const useWorkData = () => useCvStore(state => state.cv.work);
|
|||||||
export const useEducationData = () => useCvStore(state => state.cv.education);
|
export const useEducationData = () => useCvStore(state => state.cv.education);
|
||||||
export const useSkillsData = () => useCvStore(state => state.cv.skills);
|
export const useSkillsData = () => useCvStore(state => state.cv.skills);
|
||||||
export const useSummaryData = () => useCvStore(state => state.cv.summary);
|
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 useTemplateId = () => useCvStore(state => state.cv.templateId);
|
||||||
|
export const useColorTheme = () => useCvStore(state => state.cv.colorTheme);
|
||||||
export const useActiveStep = () => useCvStore(state => state.activeStep);
|
export const useActiveStep = () => useCvStore(state => state.activeStep);
|
||||||
export const useIsDirty = () => useCvStore(state => state.isDirty);
|
export const useIsDirty = () => useCvStore(state => state.isDirty);
|
||||||
export const useLastSaved = () => useCvStore(state => state.lastSaved);
|
export const useLastSaved = () => useCvStore(state => state.lastSaved);
|
||||||
|
export const useSaveStatus = () => useCvStore(state => state.saveStatus);
|
||||||
|
export const useSaveError = () => useCvStore(state => state.saveError);
|
||||||
@ -36,95 +36,63 @@ const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
|
|||||||
const { personal, summary, work, education, skills, languages, certifications } = cv;
|
const { personal, summary, work, education, skills, languages, certifications } = cv;
|
||||||
|
|
||||||
return (
|
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 */}
|
||||||
<header className="mb-6 border-b pb-6">
|
<header className="text-center mb-6">
|
||||||
<h1 className="text-3xl font-bold mb-1">
|
<h1 className="text-2xl font-bold mb-1">
|
||||||
{escapeText(personal.firstName)} {escapeText(personal.lastName)}
|
{escapeText(personal.firstName)} {escapeText(personal.lastName)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 text-sm mt-2">
|
<div className="text-xs">
|
||||||
{personal.email && (
|
{[
|
||||||
<div className="flex items-center">
|
personal.city && personal.country && `${escapeText(personal.city)}, ${escapeText(personal.country)}`,
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
personal.phone && escapeText(personal.phone),
|
||||||
<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" />
|
personal.email && escapeText(personal.email),
|
||||||
</svg>
|
...personal.links.map(link => escapeText(link.label || link.url))
|
||||||
<span>{escapeText(personal.email)}</span>
|
].filter(Boolean).join(' • ')}
|
||||||
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
{summary && (
|
{summary && (
|
||||||
<section className="mb-6">
|
<section className="mb-4">
|
||||||
<h2 className="text-xl font-bold mb-2 text-gray-700 border-b pb-1">Professional Summary</h2>
|
<h2 className="text-sm font-bold mb-2 pb-1 border-b border-black uppercase">
|
||||||
<p className="text-gray-700" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
Professional Summary
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs leading-normal" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Work Experience */}
|
{/* Work Experience */}
|
||||||
{work.length > 0 && (
|
{work.length > 0 && (
|
||||||
<section className="mb-6">
|
<section className="mb-4">
|
||||||
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Work Experience</h2>
|
<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) => (
|
{work.map((job) => (
|
||||||
<div key={job.id} className="mb-4">
|
<div key={job.id} className="mb-3">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start mb-1">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">{escapeText(job.title)}</h3>
|
<h3 className="font-bold text-xs">{escapeText(job.company)}, {formatLocation(job.location)}</h3>
|
||||||
<p className="text-gray-700">{escapeText(job.company)}</p>
|
<p className="text-xs italic">{escapeText(job.title)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-xs">
|
||||||
{formatDate(job.startDate)} - {job.endDate ? formatDate(job.endDate) : 'Present'}
|
{formatDate(job.startDate)} – {job.endDate ? formatDate(job.endDate) : 'Present'}
|
||||||
{job.location && <div>{formatLocation(job.location)}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{job.bullets.length > 0 && (
|
{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) => (
|
{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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@ -136,23 +104,23 @@ const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
|
|||||||
|
|
||||||
{/* Education */}
|
{/* Education */}
|
||||||
{education.length > 0 && (
|
{education.length > 0 && (
|
||||||
<section className="mb-6">
|
<section className="mb-4">
|
||||||
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Education</h2>
|
<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) => (
|
{education.map((edu) => (
|
||||||
<div key={edu.id} className="mb-4">
|
<div key={edu.id}>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">{escapeText(edu.degree)}</h3>
|
<h3 className="font-bold text-xs">{escapeText(edu.school)}</h3>
|
||||||
<p className="text-gray-700">{escapeText(edu.school)}</p>
|
<p className="text-xs italic">{escapeText(edu.degree)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-xs">
|
||||||
{formatDate(edu.startDate)} - {edu.endDate ? formatDate(edu.endDate) : 'Present'}
|
{edu.endDate ? formatDate(edu.endDate) : formatDate(edu.startDate)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -161,18 +129,15 @@ const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
|
|||||||
|
|
||||||
{/* Skills */}
|
{/* Skills */}
|
||||||
{skills.length > 0 && (
|
{skills.length > 0 && (
|
||||||
<section className="mb-6">
|
<section className="mb-4">
|
||||||
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Skills</h2>
|
<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) => (
|
{skills.map((skill) => (
|
||||||
<span
|
<div key={skill.id} className="flex items-center">
|
||||||
key={skill.id}
|
<span className="text-xs font-bold mr-1">•</span>
|
||||||
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
<span className="text-xs">{escapeText(skill.name)}</span>
|
||||||
>
|
</div>
|
||||||
{escapeText(skill.name)}
|
|
||||||
{skill.level && <span className="ml-1 text-gray-500">({skill.level})</span>}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
176
cv-engine/src/templates/ClassicTemplate.tsx
Normal file
176
cv-engine/src/templates/ClassicTemplate.tsx
Normal 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;
|
||||||
169
cv-engine/src/templates/MinimalTemplate.tsx
Normal file
169
cv-engine/src/templates/MinimalTemplate.tsx
Normal 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;
|
||||||
185
cv-engine/src/templates/ModernTemplate.tsx
Normal file
185
cv-engine/src/templates/ModernTemplate.tsx
Normal 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;
|
||||||
276
cv-engine/src/templates/TimelineTemplate.tsx
Normal file
276
cv-engine/src/templates/TimelineTemplate.tsx
Normal 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;
|
||||||
57
cv-engine/src/templates/registry.ts
Normal file
57
cv-engine/src/templates/registry.ts
Normal 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;
|
||||||
|
};
|
||||||
157
cv-engine/src/types/colors.ts
Normal file
157
cv-engine/src/types/colors.ts
Normal 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;
|
||||||
|
};
|
||||||
4
cv-engine/src/types/shared-printable.d.ts
vendored
Normal file
4
cv-engine/src/types/shared-printable.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
declare module '../../../shared-printable/buildPrintableHtml.js' {
|
||||||
|
import type { CV } from '../schema/cvSchema';
|
||||||
|
export function buildPrintableHtml(cv: CV): string;
|
||||||
|
}
|
||||||
12
cv-engine/src/utils/printable.ts
Normal file
12
cv-engine/src/utils/printable.ts
Normal 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);
|
||||||
|
};
|
||||||
144
cv-engine/src/utils/thumbnailGenerator.ts
Normal file
144
cv-engine/src/utils/thumbnailGenerator.ts
Normal 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)}`;
|
||||||
|
};
|
||||||
@ -24,5 +24,8 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": [
|
||||||
|
"src",
|
||||||
|
"../shared-printable/buildPrintableHtml.d.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,4 +4,9 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
allow: ['..']
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
24
cv-export-server/.gitignore
vendored
Normal file
24
cv-export-server/.gitignore
vendored
Normal 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
2123
cv-export-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
cv-export-server/package.json
Normal file
21
cv-export-server/package.json
Normal 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
286
cv-export-server/server.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
11
headers.txt
Normal 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
|
||||||
|
|
||||||
2
shared-printable/buildPrintableHtml.d.ts
vendored
Normal file
2
shared-printable/buildPrintableHtml.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export type CV = import('../cv-engine/src/schema/cvSchema').CV;
|
||||||
|
export function buildPrintableHtml(cv: CV, templateId?: string): string;
|
||||||
307
shared-printable/buildPrintableHtml.js
Normal file
307
shared-printable/buildPrintableHtml.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user