feat: initialize CV Engine project with core functionality
- Set up React + TypeScript + Vite project with TailwindCSS - Implement Zustand store for CV data management - Create personal details editor with validation - Add ATS-friendly template for CV preview - Build stepper navigation component - Include schema validation with Zod - Configure ESLint and Prettier for code quality
This commit is contained in:
commit
1bede93cd1
36
.trae/rules/project_rules.md
Normal file
36
.trae/rules/project_rules.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
You are a Senior Front-End Developer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning.
|
||||||
|
|
||||||
|
- Follow the user’s requirements carefully & to the letter.
|
||||||
|
- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.
|
||||||
|
- Confirm, then write code!
|
||||||
|
- Always check to see if there is an existing code that can be used to implement the requested functionality.
|
||||||
|
- Always check to see that no linting errors and all new code compiles without errors.
|
||||||
|
- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines .
|
||||||
|
- Focus on easy and readability code, over being performant.
|
||||||
|
- Fully implement all requested functionality.
|
||||||
|
- Leave NO todo’s, placeholders or missing pieces.
|
||||||
|
- Ensure code is complete! Verify thoroughly finalised.
|
||||||
|
- Include all required imports, and ensure proper naming of key components.
|
||||||
|
- Be concise Minimize any other prose.
|
||||||
|
- If you think there might not be a correct answer, you say so.
|
||||||
|
- If you do not know the answer, say so, instead of guessing.
|
||||||
|
|
||||||
|
### Coding Environment
|
||||||
|
The user asks questions about the following coding languages:
|
||||||
|
- ReactJS
|
||||||
|
- Vite
|
||||||
|
- Zustand
|
||||||
|
- JavaScript
|
||||||
|
- TypeScript
|
||||||
|
- TailwindCSS
|
||||||
|
- HTML
|
||||||
|
- CSS
|
||||||
|
|
||||||
|
### Code Implementation Guidelines
|
||||||
|
Follow these rules when you write code:
|
||||||
|
- Use early returns whenever possible to make the code more readable.
|
||||||
|
- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags.
|
||||||
|
- Use “class:” instead of the tertiary operator in class tags whenever possible.
|
||||||
|
- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown.
|
||||||
|
- Implement accessibility features on elements. For example, a tag should have a tabindex=“0”, aria-label, on:click, and on:keydown, and similar attributes.
|
||||||
|
- Use consts instead of functions, for example, “const toggle = () =>”. Also, define a type if possible.
|
||||||
424
Cv-engine.md
Normal file
424
Cv-engine.md
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
# CV Editor React Component — README
|
||||||
|
|
||||||
|
A reusable, plug-and-play React component that provides a CV/resume builder with:
|
||||||
|
|
||||||
|
* a form-based editor (stepper UI),
|
||||||
|
* a rich-text **Tiptap** summary editor,
|
||||||
|
* live preview (inline) and iframe-isolated printable preview (A4),
|
||||||
|
* structured JSON CV model (single source of truth),
|
||||||
|
* easy integration points for saving, exporting (server-side Puppeteer example), theming and templates.
|
||||||
|
|
||||||
|
This README explains how to install, integrate, customize, test and deploy the component in an existing React app or run it standalone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Table of contents
|
||||||
|
|
||||||
|
1. What this component does
|
||||||
|
2. Key features & design decisions
|
||||||
|
3. Quick start (install + run)
|
||||||
|
4. Component API (props & events)
|
||||||
|
5. File / folder structure (suggested)
|
||||||
|
6. How to plug into an existing React app (examples)
|
||||||
|
7. Templates: how they work & how to add more
|
||||||
|
8. Exporting PDFs (server-side Puppeteer example)
|
||||||
|
9. Security & sanitization (DOMPurify)
|
||||||
|
10. State management recommendations (Zustand + TanStack)
|
||||||
|
11. Styling and theming (Tailwind / CSS isolation)
|
||||||
|
12. Accessibility checklist
|
||||||
|
13. Testing & CI suggestions
|
||||||
|
14. Troubleshooting / common errors
|
||||||
|
15. Next steps & extension ideas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. What this component does
|
||||||
|
|
||||||
|
This component provides a full CV editor UI you can drop into your React app. It stores the CV as a structured JSON model and renders templates from that model. The summary field is a rich editor (Tiptap) that stores content as HTML/JSON; previews are rendered inline and inside an iframe (print-ready HTML). For production PDF export you can reuse the same printable HTML on the server and render it with Puppeteer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Key features & design decisions
|
||||||
|
|
||||||
|
* **Single source of truth**: the CV is a JSON object (easy to save, modify, export).
|
||||||
|
* **Templates as renderers**: templates are modular React components / HTML + CSS that accept the CV JSON and return DOM or printable HTML.
|
||||||
|
* **Rich summary**: Tiptap (ProseMirror) used for structured, extensible rich text with predictable HTML output.
|
||||||
|
* **Preview parity**: inline preview uses the same React template rendering to reduce export surprises; iframe preview isolates CSS for print fidelity.
|
||||||
|
* **Server-side parity**: printable HTML builder can be used on server in Puppeteer to generate pixel-perfect PDFs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Quick start (install + run)
|
||||||
|
|
||||||
|
### Minimal dependencies
|
||||||
|
|
||||||
|
Add these packages to your project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Core
|
||||||
|
npm install react react-dom
|
||||||
|
|
||||||
|
# UI + styling (suggested)
|
||||||
|
npm install tailwindcss
|
||||||
|
|
||||||
|
# Rich text
|
||||||
|
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-placeholder
|
||||||
|
|
||||||
|
# Optional & recommended
|
||||||
|
npm install dompurify
|
||||||
|
npm install axios
|
||||||
|
npm install zustand @tanstack/react-query
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example package.json dev/start scripts
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"start": "vite", // or react-scripts start / next dev
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "vitest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
Add the component file (e.g. `CVEditorWireframe.jsx`) to your app and import it into a page. Start your app as usual (`npm start` / `npm run dev`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Component API (props & events)
|
||||||
|
|
||||||
|
Use this component as `<CVEditor />`. Example usage:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import CVEditor from './components/CVEditorWireframe'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const initialCV = { /* optional initial JSON model */ }
|
||||||
|
|
||||||
|
async function handleSave(cvJson) {
|
||||||
|
// e.g. POST /api/cv
|
||||||
|
await axios.post('/api/cv', cvJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CVEditor
|
||||||
|
initialCV={initialCV}
|
||||||
|
templates={myTemplateList}
|
||||||
|
onSave={handleSave}
|
||||||
|
exportEndpoint="/api/export/pdf" // optional: server endpoint for export jobs
|
||||||
|
allowDownload={true}
|
||||||
|
theme="light"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
* `initialCV` — `{}` CV JSON model to pre-populate the editor (optional).
|
||||||
|
* `templates` — `[{ id, name, renderer }]` list of templates. `renderer` can be a React component that accepts `{cv}` or a function that returns printable HTML.
|
||||||
|
* `onSave(cv)` — async callback called when user saves (or autosave triggers).
|
||||||
|
* `onExportRequest(cv)` — callback to call when user requests PDF export. If provided, component can call it (you can send to server for Puppeteer).
|
||||||
|
* `exportEndpoint` — optional server endpoint URL to POST CV JSON for export.
|
||||||
|
* `allowDownload` — `boolean`, whether to show Export/Download controls.
|
||||||
|
* `theme` — `'light' | 'dark'` (optional, used to add top-level class).
|
||||||
|
* `className` — additional CSS class for outer container.
|
||||||
|
* `onChange(cv)` — optional callback fired on any CV change (useful for autosave).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. File / folder structure (suggested)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
components/
|
||||||
|
CVEditorWireframe.jsx // main component (editor + preview)
|
||||||
|
templates/
|
||||||
|
TemplateClassic.jsx
|
||||||
|
TemplateATS.jsx
|
||||||
|
editors/
|
||||||
|
RichSummaryEditor.jsx // Tiptap wrapper
|
||||||
|
utils/
|
||||||
|
printableHtml.js // buildPrintableHtml(cv)
|
||||||
|
escapeHtml.js
|
||||||
|
hooks/
|
||||||
|
useCvAutosave.js
|
||||||
|
pages/
|
||||||
|
EditorPage.jsx // where you mount CVEditor
|
||||||
|
server/
|
||||||
|
export/
|
||||||
|
exportPdf.js // Express + Puppeteer server code (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. How to plug into an existing React app
|
||||||
|
|
||||||
|
### A. As a child component (recommended)
|
||||||
|
|
||||||
|
1. Copy `CVEditorWireframe.jsx` and its dependencies into your project.
|
||||||
|
2. Import and render it inside an existing route or component.
|
||||||
|
3. Provide `initialCV`, `onSave` and `templates` props as needed.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// routes/ProfileEditor.jsx
|
||||||
|
import CVEditor from '../components/CVEditorWireframe'
|
||||||
|
import TemplateClassic from '../components/templates/TemplateClassic'
|
||||||
|
|
||||||
|
const templates = [
|
||||||
|
{ id: 'classic', name: 'Classic', renderer: TemplateClassic },
|
||||||
|
// add ATS or other templates
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ProfileEditor() {
|
||||||
|
const initialCV = {...}
|
||||||
|
async function handleSave(cv) { await api.saveCv(cv) }
|
||||||
|
|
||||||
|
return <CVEditor initialCV={initialCV} templates={templates} onSave={handleSave} exportEndpoint="/api/export/pdf" />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. As an isolated micro-frontend
|
||||||
|
|
||||||
|
If you prefer it to be self-contained: bundle the component into its own package or iframe it from a separate deployment. But prefer simple import for easier code sharing and debugging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Templates: how they work & how to add more
|
||||||
|
|
||||||
|
**Two options for templates:**
|
||||||
|
|
||||||
|
1. **React component template** — a React component that accepts `{ cv }` and renders markup. Use for inline preview and server rendering (with `renderToString`).
|
||||||
|
2. **HTML template function** — a function that receives `cv` and returns printable HTML string. Use for server-side export or when you want designers to provide standalone HTML/CSS files.
|
||||||
|
|
||||||
|
**Example React template**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
export default function TemplateClassic({ cv }) {
|
||||||
|
return (
|
||||||
|
<div className="template-root">
|
||||||
|
<h1>{cv.personal.firstName} {cv.personal.lastName}</h1>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: cv.summary }} />
|
||||||
|
{cv.work.map(w => (
|
||||||
|
<div key={w.id}>
|
||||||
|
<h3>{w.title}</h3>
|
||||||
|
<div>{w.company} • {w.period}</div>
|
||||||
|
<ul>{w.bullets.map((b,i) => <li key={i}>{b}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guidelines**
|
||||||
|
|
||||||
|
* Provide both `visual` templates (fancier CSS) and `ATS` templates (semantic, simple markup).
|
||||||
|
* Keep template CSS isolated (CSS modules, scoped styles or render in iframe).
|
||||||
|
* Use the same rendering logic on the server for PDFs to ensure parity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Exporting PDFs (server-side Puppeteer example)
|
||||||
|
|
||||||
|
### Why server-side?
|
||||||
|
|
||||||
|
Puppeteer produces consistent, pixel-perfect PDFs (server or worker) and you can offload CPU work off the client.
|
||||||
|
|
||||||
|
### Minimal Express endpoint (example)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// server/export/exportPdf.js
|
||||||
|
import express from 'express';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/export/pdf', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cv = req.body;
|
||||||
|
// reuse printable-html builder (same logic as client)
|
||||||
|
const html = buildPrintableHtml(cv);
|
||||||
|
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="${cv.personal.firstName}_${cv.personal.lastName}_CV.pdf"`
|
||||||
|
});
|
||||||
|
res.send(pdfBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to create PDF' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
|
||||||
|
* Use a worker or job queue (BullMQ) for high volume.
|
||||||
|
* Reuse the same HTML builder (`buildPrintableHtml`) on the server (move the builder to a shared package or duplicate carefully).
|
||||||
|
* If using serverless (Vercel / Netlify), be aware Puppeteer needs special setup (use `chrome-aws-lambda`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Security & sanitization (DOMPurify)
|
||||||
|
|
||||||
|
Because summary HTML comes from users, always sanitize before rendering into `srcDoc` or sending to server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install dompurify
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
const safeHtml = DOMPurify.sanitize(cv.summary)
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Sanitize on both client and server if the server stores the HTML or returns it to other users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. State management recommendations
|
||||||
|
|
||||||
|
* **Zustand** — simple local/global client state (editor draft, UI toggles).
|
||||||
|
* **TanStack Query** — server-state (fetching/saving CVs, export job status).
|
||||||
|
* Use both: keep in-memory editing state in Zustand, and use React Query for network operations (cache, optimistic updates, retries).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// useCvAutosave.js
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
export function useAutosave() {
|
||||||
|
return useMutation(cv => api.saveCv(cv), { /* ... */ })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Styling & theming
|
||||||
|
|
||||||
|
* Use **Tailwind CSS** for fast iteration: the wireframe uses tailwind utility classes.
|
||||||
|
* For templates, prefer scoped CSS (CSS modules) or provide a `template.css` imported only by the template to avoid leakage.
|
||||||
|
* For iframe preview, `srcDoc` includes template CSS directly—this isolates styles perfectly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Accessibility checklist
|
||||||
|
|
||||||
|
* All form fields have accessible labels (use `label` with `htmlFor`).
|
||||||
|
* Provide keyboard navigation for stepper and drag/reorder (if implemented).
|
||||||
|
* Ensure color contrast meets WCAG AA for text.
|
||||||
|
* Use semantic elements (`<header>`, `<main>`, `<section>`, `<ul>`) in templates.
|
||||||
|
* Add `aria-live` regions for autosave status and export progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Testing & CI suggestions
|
||||||
|
|
||||||
|
* **Unit tests**: Vitest/Jest for utils (`escapeHtml`, `buildPrintableHtml`).
|
||||||
|
* **Visual regression**: Percy / Chromatic for templates and export output.
|
||||||
|
* **E2E**: Cypress or Playwright for full editor flows (create CV → preview → export).
|
||||||
|
* **CI**: run tests + lint; optionally run Puppeteer to generate sample PDFs in a job (be mindful of resource usage).
|
||||||
|
|
||||||
|
Example unit test for `escapeHtml`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { escapeHtml } from '../utils/escapeHtml'
|
||||||
|
test('escapes special chars', () => {
|
||||||
|
expect(escapeHtml(`Tom & Jerry <script>`)).toContain('&')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Troubleshooting / common errors
|
||||||
|
|
||||||
|
* **Unterminated string constant**: often caused by unescaped newline characters inside template literals or improperly closed quotes. Ensure:
|
||||||
|
|
||||||
|
* Use backticks `` ` `` for multi-line template strings.
|
||||||
|
* Escape `\n` inside single/double quoted strings (e.g. `join('\\n')`).
|
||||||
|
* Ensure `escapeHtml` function returns properly and ends with `}`.
|
||||||
|
|
||||||
|
* **Tiptap SSR**: Tiptap relies on DOM; rendering server-side requires guarding the editor initialisation (only run in browser). Use `if (typeof window !== 'undefined')` or dynamic import.
|
||||||
|
|
||||||
|
* **Puppeteer memory/CPU**: scale PDF generation using a worker pool (e.g. `puppeteer-cluster`) or separate service.
|
||||||
|
|
||||||
|
* **Iframe `srcDoc` not rendering**: ensure the printable HTML is a full HTML document (doctype, head, meta charset). Some browsers block certain inline scripts—best to avoid inline scripts in `srcDoc`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Next steps & extension ideas
|
||||||
|
|
||||||
|
* Add **template gallery** with thumbnails and filters (ATS vs Visual).
|
||||||
|
* Add **resume parser** (import old CVs) — use Rchilli/Sovren or build simple NER workflow.
|
||||||
|
* Add **AI suggestions** (OpenAI) for summary and bullet generation — integrate as optional helper.
|
||||||
|
* Add **collaboration** (CRDT or ProseMirror-based real-time editing).
|
||||||
|
* Add **version history** and downloads per version.
|
||||||
|
* Add **internationalisation** for multi-language templates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Example: Minimal `buildPrintableHtml` and `escapeHtml` utils
|
||||||
|
|
||||||
|
```js
|
||||||
|
// utils/escapeHtml.js
|
||||||
|
export function escapeHtml(str) {
|
||||||
|
if (!str) return ''
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// utils/printableHtml.js
|
||||||
|
import { escapeHtml } from './escapeHtml'
|
||||||
|
export function buildPrintableHtml(cv) {
|
||||||
|
const css = `@page{size:A4;margin:20mm}body{font-family:Inter,Arial...}`;
|
||||||
|
const summary = cv.summary || '';
|
||||||
|
const workHtml = (cv.work || []).map(w => `
|
||||||
|
<article>
|
||||||
|
<div style="display:flex;justify-content:space-between">
|
||||||
|
<div><strong>${escapeHtml(w.title)}</strong><div>${escapeHtml(w.company)}</div></div>
|
||||||
|
<div>${escapeHtml(w.period)}</div>
|
||||||
|
</div>
|
||||||
|
<ul>${(w.bullets || []).map(b => `<li>${escapeHtml(b)}</li>`).join('')}</ul>
|
||||||
|
</article>
|
||||||
|
`).join('');
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style></head><body>
|
||||||
|
<header><h1>${escapeHtml(cv.personal.firstName)} ${escapeHtml(cv.personal.lastName)}</h1></header>
|
||||||
|
<section>${summary}</section>
|
||||||
|
<section>${workHtml}</section>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Final notes
|
||||||
|
|
||||||
|
* This component is intentionally modular: swap the templates, storage, and export backend independently.
|
||||||
|
* For production, focus on sanitization (DOMPurify), server-side rendering parity (use same templates or same printable-html builder), and performance (worker pool for PDF generation).
|
||||||
|
|
||||||
|
If you'd like, I can:
|
||||||
|
|
||||||
|
* generate a ready-to-drop `CVEditorWireframe.jsx` file (complete component) and `TemplateClassic.jsx` for you to copy, or
|
||||||
|
* provide the Express + Puppeteer server file and a docker-compose example to run the PDF service, or
|
||||||
|
* scaffold a small repo with the full stack (client + server export).
|
||||||
|
|
||||||
|
Which would you like me to produce next?
|
||||||
243
README.md
Normal file
243
README.md
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
# CV Editor Engine — Development Guide
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
- Deliver a fast, accessible CV builder with live preview and high-quality PDF export.
|
||||||
|
- Keep data structured and templates decoupled for ATS-friendly and visual layouts.
|
||||||
|
- Ensure printable parity between inline preview and final PDF output.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- Client: React, TailwindCSS, Zustand, TanStack Query, Tiptap (+ DOMPurify)
|
||||||
|
- Server: Node.js + Express for export, Puppeteer/Playwright for PDF rendering
|
||||||
|
- Validation: Zod (or Yup)
|
||||||
|
- Testing: Vitest/Jest, React Testing Library, Playwright for E2E
|
||||||
|
|
||||||
|
## Repo Layout (proposed)
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
components/ # UI building blocks (forms, lists, stepper)
|
||||||
|
editors/ # Step editors (Heading, Work, Education, Skills, Summary)
|
||||||
|
templates/ # Inline templates (ATS, Visual) + shared formatters
|
||||||
|
printable/ # Printable HTML builder and styles (@page, A4)
|
||||||
|
store/ # Zustand store, mutations and selectors
|
||||||
|
services/ # API calls (save/load/export) and helpers
|
||||||
|
utils/ # escapeHtml, sanitizers, validators, formatters
|
||||||
|
hooks/ # autosave, debounced change tracking, accessibility
|
||||||
|
server/
|
||||||
|
export/ # Express route for PDF export
|
||||||
|
tests/ # Unit, integration, E2E, visual regression
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- Strictly sanitize user HTML via `DOMPurify` and escape all text in templates.
|
||||||
|
- Keep templates pure: accept CV JSON and return JSX/HTML; no side effects.
|
||||||
|
- Scope styles via CSS modules or Tailwind; avoid global leakage.
|
||||||
|
- Maintain preview ↔ printable parity using shared formatters and data.
|
||||||
|
|
||||||
|
## Data Model (Zod-style)
|
||||||
|
```ts
|
||||||
|
const CvSchema = z.object({
|
||||||
|
personal: z.object({
|
||||||
|
firstName: z.string().min(1),
|
||||||
|
lastName: z.string().min(1),
|
||||||
|
email: z.string().email(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
street: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
postcode: z.string().optional(),
|
||||||
|
links: z.array(z.object({ label: z.string(), url: z.string().url() })).optional(),
|
||||||
|
extras: z.array(z.string()).optional(), // e.g., LinkedIn, Website, Driving licence
|
||||||
|
}),
|
||||||
|
summary: z.string().max(600).optional(),
|
||||||
|
work: z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
company: z.string().min(1),
|
||||||
|
location: z.string().optional(),
|
||||||
|
startDate: z.string(), // ISO or YYYY-MM
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
bullets: z.array(z.string().max(160)).max(6),
|
||||||
|
employmentType: z.enum(['full_time','part_time','contract','intern','freelance']).optional(),
|
||||||
|
})),
|
||||||
|
education: z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
degree: z.string().min(1),
|
||||||
|
school: z.string().min(1),
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})),
|
||||||
|
skills: z.array(z.object({ name: z.string(), level: z.enum(['Beginner','Intermediate','Advanced']).optional() })),
|
||||||
|
languages: z.array(z.object({ name: z.string(), level: z.enum(['Basic','Conversational','Fluent','Native']) })).optional(),
|
||||||
|
certifications: z.array(z.object({ name: z.string(), issuer: z.string().optional(), date: z.string().optional() })).optional(),
|
||||||
|
templateId: z.string().default('ats'),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation rules
|
||||||
|
- Required: `firstName`, `lastName`, `email`; at least one of `work` or `education`.
|
||||||
|
- Bullets: 1–6 per role; each ≤160 chars; avoid trailing punctuation.
|
||||||
|
- Summary: ≤600 chars; discourage emojis; sanitize on save/render.
|
||||||
|
- Links: normalize to `https://`; restrict protocols to `http`, `https`.
|
||||||
|
|
||||||
|
## Editor Flow
|
||||||
|
- Stepper: Heading → Work → Education → Skills → Summary → Finalize
|
||||||
|
- Each step runs field validation on blur and pre-navigation; critical errors block Next (e.g., invalid email).
|
||||||
|
- Drag-and-drop reorder for Work and Skills; inline error messages with `aria-describedby`.
|
||||||
|
|
||||||
|
## Template System
|
||||||
|
- Template API: `{ id, name, renderer(cv), thumbnail? }`
|
||||||
|
- Shared formatters: `formatDate`, `formatLocation`, `escapeText`, `sanitizeHtml`.
|
||||||
|
- Templates
|
||||||
|
- ATS: semantic lists, neutral styles, no sidebars, simple typographic accents.
|
||||||
|
- Visual: sidebar contact, accented headings, subtle color blocks (similar to sample CV).
|
||||||
|
|
||||||
|
## Inline Preview
|
||||||
|
- Render chosen template directly from CV JSON; debounce heavy updates.
|
||||||
|
- Memoize template components; isolate styles via Tailwind scopes or modules.
|
||||||
|
|
||||||
|
## Printable Preview (Iframe)
|
||||||
|
- `buildPrintableHtml(cv)` returns a complete HTML document:
|
||||||
|
- `<html>` with embedded CSS: `@page { size: A4; margin: 18mm }` and print-safe font stack.
|
||||||
|
- No external scripts; all assets inline; background printing enabled.
|
||||||
|
- Load via `iframe srcDoc`; maintain parity with inline by sharing formatters and sanitized summary.
|
||||||
|
|
||||||
|
## Export Service
|
||||||
|
- Express `POST /export/pdf`
|
||||||
|
- Input: CV JSON (validated on server)
|
||||||
|
- Compose: `buildPrintableHtml(cv)`
|
||||||
|
- Render: Puppeteer `page.setContent(html, { waitUntil: 'networkidle0' })`, `page.pdf({ format: 'A4', printBackground: true })`
|
||||||
|
- Output: `application/pdf` stream with filename `cv-<lastName>-<YYYYMMDD>.pdf`
|
||||||
|
- Client: Export button calls endpoint; stream download; show progress & errors.
|
||||||
|
|
||||||
|
## Persistence & Autosave
|
||||||
|
- Autosave: debounce 1–2s after idle; mark dirty while saving; show last-saved timestamp.
|
||||||
|
- API
|
||||||
|
- `GET /cv/:id` → load draft
|
||||||
|
- `PUT /cv/:id` → save draft
|
||||||
|
- `POST /export/pdf` → export
|
||||||
|
- Store integration via TanStack Query mutations and selectors in Zustand.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
- Labels with `htmlFor`; inputs with `aria-describedby` for errors; ensure WCAG AA contrast.
|
||||||
|
- Keyboard navigation for stepper and list CRUD; focus management after add/delete.
|
||||||
|
- Templates use semantic elements: `header`, `section`, `ul`, avoiding purely presentational markup.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Sanitize summary on input and before render; escape all template-bound text.
|
||||||
|
- Restrict allowed URL protocols; validate `mailto:` only for email display links (never user-provided).
|
||||||
|
- No inline scripts; printable builder is self-contained HTML/CSS.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
- Debounce preview/autosave; throttle rapid list operations.
|
||||||
|
- Lazy-load noncritical components (template gallery); memoize heavy subtrees.
|
||||||
|
- Avoid large assets; prefer CSS accents; keep DOM size lean.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
- Unit
|
||||||
|
- `escapeHtml`, `sanitizeHtml`, formatters, Zod validators, store selectors
|
||||||
|
- Integration
|
||||||
|
- Inline ↔ printable parity across templates
|
||||||
|
- Autosave flows and error handling
|
||||||
|
- Export success/failure
|
||||||
|
- E2E (Playwright)
|
||||||
|
- Create CV → navigate steps → validate errors → switch templates → preview → export PDF
|
||||||
|
- Visual Regression
|
||||||
|
- Printable HTML screenshots per template and key data permutations
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- Centralized error boundary; step-level error summaries; non-blocking for non-critical issues.
|
||||||
|
- Graceful server errors with retry; maintain local draft if save/export fails.
|
||||||
|
|
||||||
|
## Milestones & Tasks
|
||||||
|
|
||||||
|
### M1 — Foundations
|
||||||
|
Tasks
|
||||||
|
- Initialize client app and Tailwind; set up routing.
|
||||||
|
- Create `CvSchema`, types, and helpers (normalization, IDs).
|
||||||
|
- Implement Zustand store: CV draft, active step, templateId, UI flags.
|
||||||
|
- Build Stepper and Heading editor form with validation.
|
||||||
|
- Implement ATS inline template and preview panel.
|
||||||
|
Acceptance
|
||||||
|
- User can enter heading details with inline validation and see preview update.
|
||||||
|
- Data stored in Zustand; template renders from CV JSON.
|
||||||
|
|
||||||
|
### M2 — Content Steps
|
||||||
|
Tasks
|
||||||
|
- Work editor: CRUD, reorder, bullet editor with length guidance.
|
||||||
|
- Education editor: CRUD; reuse shared list components.
|
||||||
|
- Skills editor: tag input + quick-add chips; optional level.
|
||||||
|
Acceptance
|
||||||
|
- Add/update/delete/reorder across steps; errors appear in-place; preview updates live.
|
||||||
|
|
||||||
|
### M3 — Summary & Printable
|
||||||
|
Tasks
|
||||||
|
- Integrate Tiptap (StarterKit + Placeholder); toolbar with basic marks.
|
||||||
|
- Sanitize output; store HTML + optional JSON.
|
||||||
|
- Implement `buildPrintableHtml(cv)` and iframe preview with A4 styles.
|
||||||
|
Acceptance
|
||||||
|
- Printable preview matches inline layout (content, spacing, formatting); summary sanitized.
|
||||||
|
|
||||||
|
### M4 — Persistence
|
||||||
|
Tasks
|
||||||
|
- Wire autosave with debounced mutations; load on mount.
|
||||||
|
- Add last-saved timestamp and dirty state.
|
||||||
|
- Handle save/load errors gracefully.
|
||||||
|
Acceptance
|
||||||
|
- Draft persists across reloads; autosave feels responsive and safe.
|
||||||
|
|
||||||
|
### M5 — Export
|
||||||
|
Tasks
|
||||||
|
- Server: Express route for `POST /export/pdf` using Puppeteer.
|
||||||
|
- Client: Export button; download with progress; error UI.
|
||||||
|
Acceptance
|
||||||
|
- PDF export produces a visually faithful A4 document with correct filename.
|
||||||
|
|
||||||
|
### M6 — Templates
|
||||||
|
Tasks
|
||||||
|
- Add Visual template; gallery picker with thumbnails and filters (ATS vs Visual).
|
||||||
|
- Persist `templateId`; ensure both inline and printable support switching.
|
||||||
|
Acceptance
|
||||||
|
- Users switch templates instantly; parity holds across preview and PDF.
|
||||||
|
|
||||||
|
### M7 — QA & Polish
|
||||||
|
Tasks
|
||||||
|
- Accessibility pass; performance tuning; code cleanup.
|
||||||
|
- Unit, integration, E2E, visual tests; CI workflow.
|
||||||
|
Acceptance
|
||||||
|
- Tests green; meets accessibility and performance targets.
|
||||||
|
|
||||||
|
### M8 — Enhancements (optional)
|
||||||
|
Tasks
|
||||||
|
- Import structured CV (JSON); version history; i18n; AI assist.
|
||||||
|
Acceptance
|
||||||
|
- Optional features are behind flags and do not regress core flows.
|
||||||
|
|
||||||
|
## Setup (placeholder)
|
||||||
|
Commands to run once you scaffold the app:
|
||||||
|
```
|
||||||
|
npm create vite@latest cv-engine -- --template react-ts
|
||||||
|
cd cv-engine
|
||||||
|
npm i tailwindcss @tiptap/react @tiptap/starter-kit dompurify zustand @tanstack/react-query zod puppeteer express
|
||||||
|
npx tailwindcss init -p
|
||||||
|
```
|
||||||
|
Adapt paths to your chosen structure. This repo currently contains planning documents only.
|
||||||
|
|
||||||
|
## Acceptance Criteria Checklist (global)
|
||||||
|
- Structured CV JSON validated end-to-end
|
||||||
|
- Live inline preview with ATS template by M1
|
||||||
|
- Printable HTML parity by M3 (layout, spacing, typography, sanitization)
|
||||||
|
- Autosave with clear feedback by M4
|
||||||
|
- Reliable PDF export by M5
|
||||||
|
- At least two templates with instant switching by M6
|
||||||
|
- Accessibility AA and test coverage by M7
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
- ATS: Applicant Tracking System; favors semantic, minimal styling.
|
||||||
|
- Parity: Inline preview and exported PDF display equivalent content/layout.
|
||||||
|
- Printable builder: Function that returns a self-contained HTML document for PDF rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
Use this README as the source of truth during implementation. Update sections as architecture evolves, but keep template parity, sanitization, and accessibility as non-negotiable constraints.
|
||||||
24
cv-engine/.gitignore
vendored
Normal file
24
cv-engine/.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?
|
||||||
73
cv-engine/README.md
Normal file
73
cv-engine/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
cv-engine/eslint.config.js
Normal file
23
cv-engine/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
cv-engine/index.html
Normal file
13
cv-engine/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>cv-engine</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5025
cv-engine/package-lock.json
generated
Normal file
5025
cv-engine/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
cv-engine/package.json
Normal file
40
cv-engine/package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "cv-engine",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tiptap/react": "^3.6.5",
|
||||||
|
"@tiptap/starter-kit": "^3.6.5",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"dompurify": "^3.2.7",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"zod": "^4.1.11",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@types/react": "^19.1.16",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.45.0",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
cv-engine/postcss.config.js
Normal file
6
cv-engine/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
cv-engine/public/vite.svg
Normal file
1
cv-engine/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
cv-engine/src/App.css
Normal file
42
cv-engine/src/App.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
65
cv-engine/src/App.tsx
Normal file
65
cv-engine/src/App.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Stepper from './components/Stepper';
|
||||||
|
import PersonalEditor from './editors/PersonalEditor';
|
||||||
|
import PreviewPanel from './components/PreviewPanel';
|
||||||
|
import { useActiveStep } from './store/cvStore';
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
const activeStep = useActiveStep();
|
||||||
|
|
||||||
|
// Render the appropriate editor based on the active step
|
||||||
|
const renderEditor = () => {
|
||||||
|
switch (activeStep) {
|
||||||
|
case 'personal':
|
||||||
|
return <PersonalEditor />;
|
||||||
|
case 'work':
|
||||||
|
return <div className="p-6 bg-white rounded-lg shadow-sm">Work Experience editor will be implemented in M2</div>;
|
||||||
|
case 'education':
|
||||||
|
return <div className="p-6 bg-white rounded-lg shadow-sm">Education editor will be implemented in M2</div>;
|
||||||
|
case 'skills':
|
||||||
|
return <div className="p-6 bg-white rounded-lg shadow-sm">Skills editor will be implemented in M2</div>;
|
||||||
|
case 'summary':
|
||||||
|
return <div className="p-6 bg-white rounded-lg shadow-sm">Summary editor will be implemented in M3</div>;
|
||||||
|
case 'finalize':
|
||||||
|
return <div className="p-6 bg-white rounded-lg shadow-sm">Finalize step will be implemented in M5</div>;
|
||||||
|
default:
|
||||||
|
return <PersonalEditor />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<header className="bg-white shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">CV Engine</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<Stepper className="mb-8" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Editor Panel */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
{renderEditor()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Panel */}
|
||||||
|
<div className="lg:col-span-1 h-[calc(100vh-240px)]">
|
||||||
|
<PreviewPanel className="h-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="bg-white mt-12 py-6 border-t">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<p className="text-center text-gray-500 text-sm">
|
||||||
|
CV Engine - Milestone 1 Implementation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
cv-engine/src/assets/react.svg
Normal file
1
cv-engine/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
31
cv-engine/src/components/PreviewPanel.tsx
Normal file
31
cv-engine/src/components/PreviewPanel.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useCvStore } from '../store/cvStore';
|
||||||
|
import ATSTemplate from '../templates/ATSTemplate';
|
||||||
|
|
||||||
|
interface PreviewPanelProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||||
|
const cv = useCvStore(state => state.cv);
|
||||||
|
const templateId = useCvStore(state => state.cv.templateId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-100 rounded-lg shadow-inner overflow-auto ${className}`}>
|
||||||
|
<div className="sticky top-0 bg-gray-200 p-3 border-b flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-medium text-gray-700">Preview</h2>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Template: <span className="font-medium">{templateId === 'ats' ? 'ATS-Friendly' : templateId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Render the appropriate template based on templateId */}
|
||||||
|
{templateId === 'ats' && <ATSTemplate cv={cv} />}
|
||||||
|
{/* Add more template options here as they are implemented */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PreviewPanel;
|
||||||
113
cv-engine/src/components/Stepper.tsx
Normal file
113
cv-engine/src/components/Stepper.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useActiveStep, useCvStore, type EditorStep } from '../store/cvStore';
|
||||||
|
|
||||||
|
interface StepperProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stepper: React.FC<StepperProps> = ({ className = '' }) => {
|
||||||
|
const activeStep = useActiveStep();
|
||||||
|
const { setActiveStep, validateActiveSection, nextStep, prevStep } = useCvStore();
|
||||||
|
|
||||||
|
const steps: { id: EditorStep; label: string }[] = [
|
||||||
|
{ id: 'personal', label: 'Personal Details' },
|
||||||
|
{ id: 'work', label: 'Work Experience' },
|
||||||
|
{ id: 'education', label: 'Education' },
|
||||||
|
{ id: 'skills', label: 'Skills' },
|
||||||
|
{ id: 'summary', label: 'Summary' },
|
||||||
|
{ id: 'finalize', label: 'Finalize' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleStepClick = (step: EditorStep) => {
|
||||||
|
// If moving forward, validate current section first
|
||||||
|
const currentIndex = steps.findIndex(s => s.id === activeStep);
|
||||||
|
const targetIndex = steps.findIndex(s => s.id === step);
|
||||||
|
|
||||||
|
if (targetIndex > currentIndex) {
|
||||||
|
const isValid = validateActiveSection();
|
||||||
|
if (!isValid) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveStep(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (validateActiveSection()) {
|
||||||
|
nextStep();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full ${className}`}>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isActive = step.id === activeStep;
|
||||||
|
const isPast = steps.findIndex(s => s.id === activeStep) > index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="flex flex-col items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStepClick(step.id)}
|
||||||
|
className={`
|
||||||
|
w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium
|
||||||
|
${isActive ? 'bg-blue-600 text-white' : ''}
|
||||||
|
${isPast ? 'bg-green-500 text-white' : ''}
|
||||||
|
${!isActive && !isPast ? 'bg-gray-200 text-gray-700' : ''}
|
||||||
|
transition-colors duration-200
|
||||||
|
`}
|
||||||
|
aria-current={isActive ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
{isPast ? (
|
||||||
|
<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="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
index + 1
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className={`mt-2 text-xs font-medium ${isActive ? 'text-blue-600' : 'text-gray-500'}`}>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Connector line between steps */}
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div className="hidden sm:block absolute left-0 w-full">
|
||||||
|
<div
|
||||||
|
className={`h-0.5 ${isPast ? 'bg-green-500' : 'bg-gray-200'}`}
|
||||||
|
style={{ width: `${100 / (steps.length - 1)}%`, marginLeft: `${(index * 100) / (steps.length - 1)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-8">
|
||||||
|
<button
|
||||||
|
onClick={prevStep}
|
||||||
|
disabled={activeStep === 'personal'}
|
||||||
|
className={`
|
||||||
|
px-4 py-2 rounded-md text-sm font-medium
|
||||||
|
${activeStep === 'personal' ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={activeStep === 'finalize'}
|
||||||
|
className={`
|
||||||
|
px-4 py-2 rounded-md text-sm font-medium
|
||||||
|
${activeStep === 'finalize' ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-blue-600 text-white hover:bg-blue-700'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{activeStep === 'finalize' ? 'Complete' : 'Next'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stepper;
|
||||||
347
cv-engine/src/editors/PersonalEditor.tsx
Normal file
347
cv-engine/src/editors/PersonalEditor.tsx
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { usePersonalData, useCvStore } from '../store/cvStore';
|
||||||
|
import { normalizeUrl } from '../schema/cvSchema';
|
||||||
|
|
||||||
|
const PersonalEditor: React.FC = () => {
|
||||||
|
const personalData = usePersonalData();
|
||||||
|
const { updatePersonal } = useCvStore();
|
||||||
|
const [formData, setFormData] = useState(personalData);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFormData(personalData);
|
||||||
|
}, [personalData]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({ ...prev, [name]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
|
||||||
|
// Validate on blur
|
||||||
|
if (name === 'firstName' && !value.trim()) {
|
||||||
|
setErrors(prev => ({ ...prev, firstName: 'First name is required' }));
|
||||||
|
} else if (name === 'lastName' && !value.trim()) {
|
||||||
|
setErrors(prev => ({ ...prev, lastName: 'Last name is required' }));
|
||||||
|
} else if (name === 'email') {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!value.trim()) {
|
||||||
|
setErrors(prev => ({ ...prev, email: 'Email is required' }));
|
||||||
|
} else if (!emailRegex.test(value)) {
|
||||||
|
setErrors(prev => ({ ...prev, email: 'Invalid email format' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update store on blur if no errors
|
||||||
|
if (!Object.values(errors).some(error => error)) {
|
||||||
|
updatePersonal(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddLink = () => {
|
||||||
|
const newLinks = [...formData.links, { id: Math.random().toString(36).substring(2, 9), label: '', url: '' }];
|
||||||
|
setFormData(prev => ({ ...prev, links: newLinks }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkChange = (id: string, field: 'label' | 'url', value: string) => {
|
||||||
|
const updatedLinks = formData.links.map(link =>
|
||||||
|
link.id === id ? { ...link, [field]: value } : link
|
||||||
|
);
|
||||||
|
setFormData(prev => ({ ...prev, links: updatedLinks }));
|
||||||
|
|
||||||
|
// Clear link errors
|
||||||
|
if (errors[`link-${id}-${field}`]) {
|
||||||
|
setErrors(prev => ({ ...prev, [`link-${id}-${field}`]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkBlur = (id: string, field: 'label' | 'url', value: string) => {
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
if (field === 'url' && value) {
|
||||||
|
try {
|
||||||
|
// Normalize and validate URL
|
||||||
|
const normalizedUrl = normalizeUrl(value);
|
||||||
|
const updatedLinks = formData.links.map(link =>
|
||||||
|
link.id === id ? { ...link, url: normalizedUrl } : link
|
||||||
|
);
|
||||||
|
setFormData(prev => ({ ...prev, links: updatedLinks }));
|
||||||
|
} catch {
|
||||||
|
setErrors(prev => ({ ...prev, [`link-${id}-url`]: 'Invalid URL format' }));
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasError) {
|
||||||
|
updatePersonal(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLink = (id: string) => {
|
||||||
|
const updatedLinks = formData.links.filter(link => link.id !== id);
|
||||||
|
const updatedFormData = { ...formData, links: updatedLinks };
|
||||||
|
setFormData(updatedFormData);
|
||||||
|
updatePersonal(updatedFormData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validate all fields
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
if (!formData.firstName.trim()) newErrors.firstName = 'First name is required';
|
||||||
|
if (!formData.lastName.trim()) newErrors.lastName = 'Last name is required';
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!formData.email.trim()) {
|
||||||
|
newErrors.email = 'Email is required';
|
||||||
|
} else if (!emailRegex.test(formData.email)) {
|
||||||
|
newErrors.email = 'Invalid email format';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate links
|
||||||
|
formData.links.forEach(link => {
|
||||||
|
if (link.url && !link.url.startsWith('http')) {
|
||||||
|
newErrors[`link-${link.id}-url`] = 'URL must start with http:// or https://';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
|
||||||
|
// Update store if no errors
|
||||||
|
if (Object.keys(newErrors).length === 0) {
|
||||||
|
updatePersonal(formData);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow-sm">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Personal Information</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* First Name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
First Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md ${errors.firstName ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
|
aria-describedby={errors.firstName ? "firstName-error" : undefined}
|
||||||
|
/>
|
||||||
|
{errors.firstName && (
|
||||||
|
<p id="firstName-error" className="mt-1 text-sm text-red-600">
|
||||||
|
{errors.firstName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Last Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md ${errors.lastName ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
|
aria-describedby={errors.lastName ? "lastName-error" : undefined}
|
||||||
|
/>
|
||||||
|
{errors.lastName && (
|
||||||
|
<p id="lastName-error" className="mt-1 text-sm text-red-600">
|
||||||
|
{errors.lastName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
|
aria-describedby={errors.email ? "email-error" : undefined}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p id="email-error" className="mt-1 text-sm text-red-600">
|
||||||
|
{errors.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Phone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
value={formData.phone || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Fields */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="street" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Street Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="street"
|
||||||
|
name="street"
|
||||||
|
value={formData.street || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
City
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="city"
|
||||||
|
name="city"
|
||||||
|
value={formData.city || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="country" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Country
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="country"
|
||||||
|
name="country"
|
||||||
|
value={formData.country || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="postcode" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Postal Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="postcode"
|
||||||
|
name="postcode"
|
||||||
|
value={formData.postcode || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links Section */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h3 className="text-lg font-medium">Links</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddLink}
|
||||||
|
className="px-3 py-1 bg-blue-50 text-blue-600 rounded-md text-sm hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
Add Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.links.map((link) => (
|
||||||
|
<div key={link.id} className="flex items-start space-x-2 mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Label (e.g. LinkedIn)"
|
||||||
|
value={link.label}
|
||||||
|
onChange={(e) => handleLinkChange(link.id, 'label', e.target.value)}
|
||||||
|
onBlur={(e) => handleLinkBlur(link.id, 'label', e.target.value)}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md ${
|
||||||
|
errors[`link-${link.id}-label`] ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors[`link-${link.id}-label`] && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors[`link-${link.id}-label`]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="URL (e.g. https://linkedin.com/in/...)"
|
||||||
|
value={link.url}
|
||||||
|
onChange={(e) => handleLinkChange(link.id, 'url', e.target.value)}
|
||||||
|
onBlur={(e) => handleLinkBlur(link.id, 'url', e.target.value)}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md ${
|
||||||
|
errors[`link-${link.id}-url`] ? 'border-red-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors[`link-${link.id}-url`] && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors[`link-${link.id}-url`]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveLink(link.id)}
|
||||||
|
className="mt-2 text-red-500 hover:text-red-700"
|
||||||
|
aria-label="Remove link"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonalEditor;
|
||||||
11
cv-engine/src/index.css
Normal file
11
cv-engine/src/index.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global body styles moved to component classes; avoid @apply here */
|
||||||
|
}
|
||||||
10
cv-engine/src/main.tsx
Normal file
10
cv-engine/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
135
cv-engine/src/schema/cvSchema.ts
Normal file
135
cv-engine/src/schema/cvSchema.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Helper function to generate unique IDs
|
||||||
|
export const generateId = () => Math.random().toString(36).substring(2, 9);
|
||||||
|
|
||||||
|
// CV Schema using Zod for validation
|
||||||
|
export const CvSchema = z.object({
|
||||||
|
personal: z.object({
|
||||||
|
firstName: z.string().min(1, "First name is required"),
|
||||||
|
lastName: z.string().min(1, "Last name is required"),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
street: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
postcode: z.string().optional(),
|
||||||
|
links: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().default(() => generateId()),
|
||||||
|
label: z.string(),
|
||||||
|
url: z.string().url("Invalid URL format")
|
||||||
|
})
|
||||||
|
).optional().default([]),
|
||||||
|
extras: z.array(z.string()).optional().default([]),
|
||||||
|
}),
|
||||||
|
summary: z.string().max(600, "Summary should be less than 600 characters").optional().default(""),
|
||||||
|
work: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().default(() => generateId()),
|
||||||
|
title: z.string().min(1, "Job title is required"),
|
||||||
|
company: z.string().min(1, "Company name is required"),
|
||||||
|
location: z.string().optional(),
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
bullets: z.array(z.string().max(160, "Each bullet point should be less than 160 characters")).max(6, "Maximum 6 bullet points allowed"),
|
||||||
|
employmentType: z.enum(['full_time', 'part_time', 'contract', 'intern', 'freelance']).optional(),
|
||||||
|
})
|
||||||
|
).default([]),
|
||||||
|
education: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().default(() => generateId()),
|
||||||
|
degree: z.string().min(1, "Degree is required"),
|
||||||
|
school: z.string().min(1, "School name is required"),
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
).default([]),
|
||||||
|
skills: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().default(() => generateId()),
|
||||||
|
name: z.string(),
|
||||||
|
level: z.enum(['Beginner', 'Intermediate', 'Advanced']).optional()
|
||||||
|
})
|
||||||
|
).default([]),
|
||||||
|
languages: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().default(() => generateId()),
|
||||||
|
name: z.string(),
|
||||||
|
level: z.enum(['Basic', 'Conversational', 'Fluent', 'Native'])
|
||||||
|
})
|
||||||
|
).optional().default([]),
|
||||||
|
certifications: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().default(() => generateId()),
|
||||||
|
name: z.string(),
|
||||||
|
issuer: z.string().optional(),
|
||||||
|
date: z.string().optional()
|
||||||
|
})
|
||||||
|
).optional().default([]),
|
||||||
|
templateId: z.string().default('ats'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type inference from the schema
|
||||||
|
export type CV = z.infer<typeof CvSchema>;
|
||||||
|
|
||||||
|
// Create an empty CV with default values
|
||||||
|
export const createEmptyCV = (): CV => ({
|
||||||
|
personal: {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
street: "",
|
||||||
|
city: "",
|
||||||
|
country: "",
|
||||||
|
postcode: "",
|
||||||
|
links: [],
|
||||||
|
extras: [],
|
||||||
|
},
|
||||||
|
summary: "",
|
||||||
|
work: [],
|
||||||
|
education: [],
|
||||||
|
skills: [],
|
||||||
|
languages: [],
|
||||||
|
certifications: [],
|
||||||
|
templateId: "ats",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions for validation
|
||||||
|
export const validateCV = (cv: CV) => {
|
||||||
|
return CvSchema.safeParse(cv);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateSection = <K extends keyof CV>(section: K, data: CV[K]) => {
|
||||||
|
const sectionSchema = CvSchema.shape[section];
|
||||||
|
return sectionSchema.safeParse(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanitization helpers
|
||||||
|
export const sanitizeHtml = (html: string): string => {
|
||||||
|
// In a real implementation, we would use DOMPurify here
|
||||||
|
// This is a simple placeholder
|
||||||
|
return html
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
|
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
|
||||||
|
.replace(/<[^>]*>/g, ''); // Remove all HTML tags for now
|
||||||
|
};
|
||||||
|
|
||||||
|
export const escapeText = (text: string): string => {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL validation and normalization
|
||||||
|
export const normalizeUrl = (url: string): string => {
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
return `https://${url}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
306
cv-engine/src/store/cvStore.ts
Normal file
306
cv-engine/src/store/cvStore.ts
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { CvSchema, createEmptyCV, validateSection } from '../schema/cvSchema';
|
||||||
|
|
||||||
|
// Define the steps for the CV editor
|
||||||
|
export type EditorStep = 'personal' | 'work' | 'education' | 'skills' | 'summary' | 'finalize';
|
||||||
|
|
||||||
|
// Define the store state
|
||||||
|
interface CvState {
|
||||||
|
// CV data
|
||||||
|
cv: z.infer<typeof CvSchema>;
|
||||||
|
// Editor state
|
||||||
|
activeStep: EditorStep;
|
||||||
|
isDirty: boolean;
|
||||||
|
lastSaved: Date | null;
|
||||||
|
errors: Record<string, string[]>;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setCv: (cv: z.infer<typeof CvSchema>) => void;
|
||||||
|
updatePersonal: (personal: z.infer<typeof CvSchema>['personal']) => void;
|
||||||
|
updateWork: (work: z.infer<typeof CvSchema>['work']) => void;
|
||||||
|
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
|
||||||
|
updateSkills: (skills: z.infer<typeof CvSchema>['skills']) => void;
|
||||||
|
updateSummary: (summary: string) => void;
|
||||||
|
updateTemplateId: (templateId: string) => void;
|
||||||
|
|
||||||
|
// Work item operations
|
||||||
|
addWorkItem: () => void;
|
||||||
|
updateWorkItem: (id: string, item: Partial<z.infer<typeof CvSchema>['work'][0]>) => void;
|
||||||
|
removeWorkItem: (id: string) => void;
|
||||||
|
reorderWorkItems: (fromIndex: number, toIndex: number) => void;
|
||||||
|
|
||||||
|
// Education item operations
|
||||||
|
addEducationItem: () => void;
|
||||||
|
updateEducationItem: (id: string, item: Partial<z.infer<typeof CvSchema>['education'][0]>) => void;
|
||||||
|
removeEducationItem: (id: string) => void;
|
||||||
|
reorderEducationItems: (fromIndex: number, toIndex: number) => void;
|
||||||
|
|
||||||
|
// Skill operations
|
||||||
|
addSkill: (name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
|
||||||
|
updateSkill: (id: string, name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
|
||||||
|
removeSkill: (id: string) => void;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
setActiveStep: (step: EditorStep) => void;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
|
||||||
|
// State management
|
||||||
|
setDirty: (isDirty: boolean) => void;
|
||||||
|
setLastSaved: (date: Date | null) => void;
|
||||||
|
validateActiveSection: () => boolean;
|
||||||
|
resetErrors: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the store
|
||||||
|
export const useCvStore = create<CvState>((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
cv: createEmptyCV(),
|
||||||
|
activeStep: 'personal',
|
||||||
|
isDirty: false,
|
||||||
|
lastSaved: null,
|
||||||
|
errors: {},
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setCv: (cv) => set({ cv, isDirty: true }),
|
||||||
|
|
||||||
|
updatePersonal: (personal) => {
|
||||||
|
const result = validateSection('personal', personal);
|
||||||
|
if (result.success) {
|
||||||
|
set((state) => ({
|
||||||
|
cv: { ...state.cv, personal },
|
||||||
|
isDirty: true,
|
||||||
|
errors: { ...state.errors, personal: [] }
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
set((state) => ({
|
||||||
|
errors: {
|
||||||
|
...state.errors,
|
||||||
|
personal: result.error.issues.map(i => i.message)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWork: (work) => {
|
||||||
|
set((state) => ({
|
||||||
|
cv: { ...state.cv, work },
|
||||||
|
isDirty: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEducation: (education) => {
|
||||||
|
set((state) => ({
|
||||||
|
cv: { ...state.cv, education },
|
||||||
|
isDirty: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSkills: (skills) => {
|
||||||
|
set((state) => ({
|
||||||
|
cv: { ...state.cv, skills },
|
||||||
|
isDirty: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSummary: (summary) => {
|
||||||
|
set((state) => ({
|
||||||
|
cv: { ...state.cv, summary },
|
||||||
|
isDirty: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTemplateId: (templateId) => {
|
||||||
|
set((state) => ({
|
||||||
|
cv: { ...state.cv, templateId },
|
||||||
|
isDirty: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Work item operations
|
||||||
|
addWorkItem: () => {
|
||||||
|
const { cv } = get();
|
||||||
|
const newItem = {
|
||||||
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
|
title: '',
|
||||||
|
company: '',
|
||||||
|
startDate: '',
|
||||||
|
bullets: []
|
||||||
|
};
|
||||||
|
set({
|
||||||
|
cv: { ...cv, work: [newItem, ...cv.work] },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWorkItem: (id, item) => {
|
||||||
|
const { cv } = get();
|
||||||
|
const updatedWork = cv.work.map(w =>
|
||||||
|
w.id === id ? { ...w, ...item } : w
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
cv: { ...cv, work: updatedWork },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeWorkItem: (id) => {
|
||||||
|
const { cv } = get();
|
||||||
|
set({
|
||||||
|
cv: { ...cv, work: cv.work.filter(w => w.id !== id) },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderWorkItems: (fromIndex, toIndex) => {
|
||||||
|
const { cv } = get();
|
||||||
|
const work = [...cv.work];
|
||||||
|
const [removed] = work.splice(fromIndex, 1);
|
||||||
|
work.splice(toIndex, 0, removed);
|
||||||
|
set({
|
||||||
|
cv: { ...cv, work },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Education item operations
|
||||||
|
addEducationItem: () => {
|
||||||
|
const { cv } = get();
|
||||||
|
const newItem = {
|
||||||
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
|
degree: '',
|
||||||
|
school: '',
|
||||||
|
startDate: ''
|
||||||
|
};
|
||||||
|
set({
|
||||||
|
cv: { ...cv, education: [newItem, ...cv.education] },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEducationItem: (id, item) => {
|
||||||
|
const { cv } = get();
|
||||||
|
const updatedEducation = cv.education.map(e =>
|
||||||
|
e.id === id ? { ...e, ...item } : e
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
cv: { ...cv, education: updatedEducation },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeEducationItem: (id) => {
|
||||||
|
const { cv } = get();
|
||||||
|
set({
|
||||||
|
cv: { ...cv, education: cv.education.filter(e => e.id !== id) },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderEducationItems: (fromIndex, toIndex) => {
|
||||||
|
const { cv } = get();
|
||||||
|
const education = [...cv.education];
|
||||||
|
const [removed] = education.splice(fromIndex, 1);
|
||||||
|
education.splice(toIndex, 0, removed);
|
||||||
|
set({
|
||||||
|
cv: { ...cv, education },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Skill operations
|
||||||
|
addSkill: (name, level) => {
|
||||||
|
const { cv } = get();
|
||||||
|
const newSkill = {
|
||||||
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
|
name,
|
||||||
|
level
|
||||||
|
};
|
||||||
|
set({
|
||||||
|
cv: { ...cv, skills: [...cv.skills, newSkill] },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSkill: (id, name, level) => {
|
||||||
|
const { cv } = get();
|
||||||
|
const updatedSkills = cv.skills.map(s =>
|
||||||
|
s.id === id ? { ...s, name, level } : s
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
cv: { ...cv, skills: updatedSkills },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSkill: (id) => {
|
||||||
|
const { cv } = get();
|
||||||
|
set({
|
||||||
|
cv: { ...cv, skills: cv.skills.filter(s => s.id !== id) },
|
||||||
|
isDirty: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
setActiveStep: (activeStep) => set({ activeStep }),
|
||||||
|
|
||||||
|
nextStep: () => {
|
||||||
|
const { activeStep } = get();
|
||||||
|
const steps: EditorStep[] = ['personal', 'work', 'education', 'skills', 'summary', 'finalize'];
|
||||||
|
const currentIndex = steps.indexOf(activeStep);
|
||||||
|
if (currentIndex < steps.length - 1) {
|
||||||
|
set({ activeStep: steps[currentIndex + 1] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
prevStep: () => {
|
||||||
|
const { activeStep } = get();
|
||||||
|
const steps: EditorStep[] = ['personal', 'work', 'education', 'skills', 'summary', 'finalize'];
|
||||||
|
const currentIndex = steps.indexOf(activeStep);
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
set({ activeStep: steps[currentIndex - 1] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// State management
|
||||||
|
setDirty: (isDirty) => set({ isDirty }),
|
||||||
|
setLastSaved: (lastSaved) => set({ lastSaved }),
|
||||||
|
|
||||||
|
validateActiveSection: () => {
|
||||||
|
const { activeStep, cv } = get();
|
||||||
|
|
||||||
|
// Map step to CV section
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (activeStep === 'personal') {
|
||||||
|
const result = validateSection('personal', cv.personal);
|
||||||
|
isValid = result.success;
|
||||||
|
if (!result.success) {
|
||||||
|
set((state) => ({
|
||||||
|
errors: {
|
||||||
|
...state.errors,
|
||||||
|
personal: result.error.issues.map(i => i.message)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add validation for other sections as needed
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetErrors: () => set({ errors: {} })
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Selector hooks for specific parts of the state
|
||||||
|
export const usePersonalData = () => useCvStore(state => state.cv.personal);
|
||||||
|
export const useWorkData = () => useCvStore(state => state.cv.work);
|
||||||
|
export const useEducationData = () => useCvStore(state => state.cv.education);
|
||||||
|
export const useSkillsData = () => useCvStore(state => state.cv.skills);
|
||||||
|
export const useSummaryData = () => useCvStore(state => state.cv.summary);
|
||||||
|
export const useTemplateId = () => useCvStore(state => state.cv.templateId);
|
||||||
|
export const useActiveStep = () => useCvStore(state => state.activeStep);
|
||||||
|
export const useIsDirty = () => useCvStore(state => state.isDirty);
|
||||||
|
export const useLastSaved = () => useCvStore(state => state.lastSaved);
|
||||||
220
cv-engine/src/templates/ATSTemplate.tsx
Normal file
220
cv-engine/src/templates/ATSTemplate.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { CvSchema, escapeText, sanitizeHtml } from '../schema/cvSchema';
|
||||||
|
|
||||||
|
interface ATSTemplateProps {
|
||||||
|
cv: z.infer<typeof CvSchema>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date helper
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle YYYY-MM format
|
||||||
|
if (/^\d{4}-\d{2}$/.test(dateStr)) {
|
||||||
|
const [year, month] = dateStr.split('-');
|
||||||
|
const date = new Date(parseInt(year), parseInt(month) - 1);
|
||||||
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ISO date
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||||
|
} catch {
|
||||||
|
return dateStr; // Fallback to original string if parsing fails
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format location helper
|
||||||
|
const formatLocation = (location?: string): string => {
|
||||||
|
return location || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
|
||||||
|
const { personal, summary, work, education, skills, languages, certifications } = cv;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white text-gray-800 p-8 max-w-4xl mx-auto ${className}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="mb-6 border-b pb-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-1">
|
||||||
|
{escapeText(personal.firstName)} {escapeText(personal.lastName)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm mt-2">
|
||||||
|
{personal.email && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>{escapeText(personal.email)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{personal.phone && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
|
</svg>
|
||||||
|
<span>{escapeText(personal.phone)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(personal.city || personal.country) && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{personal.city && escapeText(personal.city)}
|
||||||
|
{personal.city && personal.country && ', '}
|
||||||
|
{personal.country && escapeText(personal.country)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{personal.links.map((link) => (
|
||||||
|
<div key={link.id} className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||||
|
</svg>
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{escapeText(link.label || link.url)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{summary && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-xl font-bold mb-2 text-gray-700 border-b pb-1">Professional Summary</h2>
|
||||||
|
<p className="text-gray-700" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Work Experience */}
|
||||||
|
{work.length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Work Experience</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{work.map((job) => (
|
||||||
|
<div key={job.id} className="mb-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{escapeText(job.title)}</h3>
|
||||||
|
<p className="text-gray-700">{escapeText(job.company)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{formatDate(job.startDate)} - {job.endDate ? formatDate(job.endDate) : 'Present'}
|
||||||
|
{job.location && <div>{formatLocation(job.location)}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.bullets.length > 0 && (
|
||||||
|
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||||
|
{job.bullets.map((bullet, index) => (
|
||||||
|
<li key={index} className="text-gray-700">{escapeText(bullet)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Education */}
|
||||||
|
{education.length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Education</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{education.map((edu) => (
|
||||||
|
<div key={edu.id} className="mb-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{escapeText(edu.degree)}</h3>
|
||||||
|
<p className="text-gray-700">{escapeText(edu.school)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{formatDate(edu.startDate)} - {edu.endDate ? formatDate(edu.endDate) : 'Present'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{edu.notes && <p className="mt-1 text-gray-700">{escapeText(edu.notes)}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills */}
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Skills</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<span
|
||||||
|
key={skill.id}
|
||||||
|
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
||||||
|
>
|
||||||
|
{escapeText(skill.name)}
|
||||||
|
{skill.level && <span className="ml-1 text-gray-500">({skill.level})</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Languages */}
|
||||||
|
{languages.length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Languages</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{languages.map((language) => (
|
||||||
|
<span
|
||||||
|
key={language.id}
|
||||||
|
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
||||||
|
>
|
||||||
|
{escapeText(language.name)}
|
||||||
|
{language.level && <span className="ml-1 text-gray-500">({language.level})</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Certifications */}
|
||||||
|
{certifications.length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Certifications</h2>
|
||||||
|
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
{certifications.map((cert) => (
|
||||||
|
<li key={cert.id} className="text-gray-700">
|
||||||
|
{escapeText(cert.name)}
|
||||||
|
{cert.issuer && <span className="ml-1">- {escapeText(cert.issuer)}</span>}
|
||||||
|
{cert.date && <span className="ml-1 text-gray-500">({formatDate(cert.date)})</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ATSTemplate;
|
||||||
11
cv-engine/tailwind.config.js
Normal file
11
cv-engine/tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
28
cv-engine/tsconfig.app.json
Normal file
28
cv-engine/tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
cv-engine/tsconfig.json
Normal file
7
cv-engine/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
cv-engine/tsconfig.node.json
Normal file
26
cv-engine/tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
cv-engine/vite.config.ts
Normal file
7
cv-engine/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user