diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e6ffa32 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.next +out +.git +.gitignore +docs +*.md +Dockerfile +docker-compose.yml +.dockerignore +*.log +.env* +.DS_Store \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ef6a52..342f178 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# service worker (généré par @serwist/cli) +public/sw* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1534a4f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# ── Étape de dépendances ──────────────────────────────────────── +FROM node:20-alpine AS deps +RUN corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +# ── Étape de build ───────────────────────────────────────────── +FROM node:20-alpine AS builder +RUN corepack enable +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Build de production ; la PWA (Serwist) génère public/sw.js ici +RUN pnpm build + +# ── Étape d'exécution ────────────────────────────────────────── +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +# .next/standalone auto-contain server.js, package.json… +COPY --from=builder /app/.next/standalone ./ +# Assets statiques (CSS/JS optimisés) + public/ (manifest, icônes, sw.js) +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +EXPOSE 3000 +CMD ["node", "server.js"] \ No newline at end of file diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000..c15c02b --- /dev/null +++ b/GOAL.md @@ -0,0 +1,64 @@ +# CV-Maker - Créez votre CV + +CV-Maker est une application web permettant de créer son CV rapidement et simplement. + +## Interface + +### Aspect général + +- desktop first. Tablette et mobile non prioritaires (nice to have) +- l'interface se compose de deux colonnes (1/3 - 2/3) +- partie latérale gauche : zones de saisie +- partie latérale droite : preview temps réel en fonction des saisies dans les zones de texte : format A4 +- la saisie dans les zones de texte se fait en markdown et rendue en html dans les zones cibles +- un stockage dans le localstorage se fait en temps réel à chaque frappe pour conserver les données en cas de rechargement de la page +- possibilité d'imprimer le cv en pdf +- une page de documentation +- 1 page de rgpd + +### Panneau latéral gauche + +La zone de saisie est un textarea dans lesquel sont saisis des textes en markdown. + +Un système de sélection de la zone affiche la zone grâce à un système de tab 1 à 5 + +1. zone message d'accroche +2. zone état civil + contacts + site web + github +3. zone formations +4. zone soft skills +5. zone centres d'intérêt + +La zone de saisie expériences sert à saisir toutes les expériences. + +Un bouton + permet d'ajouter une nouvelle expérience + +Une liste numérotée des expérences s'affiche sous la zone de sasie (titre bouton modif, bouton x supprimer) + +Toute nouvelle expérience s'ajoute dans la zone de preview Expériences professionnelles de haut en bas + +### Panneau latéral droit + +Le panneau latéral droit représente une feuille format A4. La mise en page doit représenter le cv imprimable. + +Ce panneau comporte un header pour le preview du message d'accroche + +Une partie gauche 1/3 de la largeur représente les sections 2 à 5 + +La partie droite 2/3 de la largeur représente les preview des expériences. Le titre est Expériences professionnelles + +- preview temps réel au format A4 +- header : message d'accroche correspond zone saisie 1 +- Etat civil : correspond zone de saisie 2 +- Formations : correspond zone de saisie 3 +- Soft skills : correspond zone de saisie 4 +- Centres d'intérêt : correspond zone de saisie + +## Stack technique prévisionnelle + +- Next Js +- Tailwind +- marked +- dompurify +- pwa +- pas de test pour démarrer (à prévoir pour plus tard) +- composants réutilisables \ No newline at end of file diff --git a/README.md b/README.md index e215bc4..285441d 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,245 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# CV-Maker — Documentation technique -## Getting Started +Application web de création de CV en temps réel : saisie **Markdown** dans un panneau latéral, **preview A4** instantanée, sauvegarde locale automatique et export **PDF** — le tout sans backend, 100 % côté navigateur. -First, run the development server: +## Sommaire + +1. [Prérequis](#prérequis) +2. [Installation et lancement](#installation-et-lancement) +3. [Scripts](#scripts) +4. [Stack technique](#stack-technique) +5. [Structure du projet](#structure-du-projet) +6. [Modèle de données](#modèle-de-données) +7. [Fonctionnement clé](#fonctionnement-clé) + - [Rendu Markdown sécurisé](#rendu-markdown-sécurisé) + - [Persistance locale](#persistance-locale) + - [Gestion des expériences](#gestion-des-expériences) + - [Impression PDF](#impression-pdf) + - [PWA](#pwa) +8. [Conception détaillée](#conception-détaillée) +9. [Limitations connues](#limitations-connues) + +--- + +## Prérequis + +| Outil | Version minimale | +| --- | --- | +| Node.js | 20.9+ | +| pnpm | 12.x (`corepack enable` puis `pnpm -v` — figé via `packageManager`) | + +## Installation et lancement ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +# 1. Installer les dépendances +pnpm install + +# 2. Lancer en développement +pnpm dev # → http://localhost:3000 + +# 3. Construire + servir en production +pnpm build +pnpm start ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Scripts -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +| Script | Commande | Description | +| --- | --- | --- | +| `dev` | `pnpm dev` | Serveur de développement (Turbopack) | +| `dev:sw` | `concurrently …` | Dev + re-build du service worker en continu (`--watch`) | +| `build` | `next build && serwist build` | Build de production + génération du service worker | +| `start` | `pnpm start` | Lancement du build de production | +| `lint` | `pnpm lint` | Vérification ESLint (Next.js) | +| `typecheck` | `tsc --noEmit` | Validation TypeScript strict | -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Stack technique -## Learn More +| Brique | Choix | Rôle | +| --- | --- | --- | +| Framework | Next.js (App Router) | Routage, composants serveur/client, métadonnées | +| Langage | TypeScript (strict) | Typage du modèle de données | +| Styles | Tailwind CSS | Design tokens, responsive, print | +| Markdown | `marked` | Conversion Markdown → HTML | +| Sécurité | `dompurify` | Assainissement du HTML avant injection (anti-XSS) | +| Persistance | `localStorage` | Données locales, aucune transmission | +| PWA | `@serwist/next` + `@serwist/cli` | Service worker (mode configurator), manifest, installabilité | +| Icônes | `lucide-react` | Iconographie | +| Conteneurisation | Docker (multi-stage) | Image de production, distribution Docker Hub | -To learn more about Next.js, take a look at the following resources: +## Structure du projet -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +``` +cv-maker/ +├── app/ +│ ├── layout.tsx # Layout racine (lang, meta, manifest) +│ ├── page.tsx # Éditeur principal +│ ├── globals.css # Tokens Tailwind + @media print +│ ├── documentation/ +│ │ └── page.tsx # Page d'aide (statique) +│ ├── mentions-legales/ +│ │ └── page.tsx # Page Mentions légales (statique) +│ └── sw.ts # Source du service worker (Serwist) +├── components/ +│ ├── ui/ # Primitives réutilisables +│ │ ├── Button.tsx +│ │ ├── IconButton.tsx +│ │ ├── Textarea.tsx +│ │ ├── Tabs.tsx +│ │ └── Footer.tsx # Pied de page (lien Mentions légales) +│ └── cv/ # Composants métier du CV +│ ├── CvWorkspace.tsx # Assemblage 2 colonnes + toolbar +│ ├── CvToolbar.tsx # Barre d'app (PDF, JSON, liens) +│ ├── CvInputPanel.tsx # Panneau gauche (tabs + expériences) +│ ├── CvTabs.tsx # Onglets 1..5 +│ ├── MarkdownTextarea.tsx # Textarea contrôlé +│ ├── ExperienceManager.tsx # Formulaire + liste expériences +│ ├── ExperienceItem.tsx # Ligne « n. titre [Modifier] [×] » +│ ├── CvPreview.tsx # Feuille A4 +│ ├── CvHeader.tsx # Accroche (zone 1) +│ ├── CvLeftColumn.tsx # Sections 2→5 (1/3) +│ ├── CvSection.tsx # Section générique +│ ├── CvRightColumn.tsx # Expériences pro (2/3) +│ └── CvExperiencePreview.tsx +├── lib/ +│ ├── types/cv.ts # Contracts du domaine +│ ├── markdown.ts # markdownToHtml (marked + DOMPurify) +│ ├── storage.ts # Adapter localStorage +│ └── hooks/ +│ ├── useLocalStorage.ts # Persistance générique +│ └── useCVData.ts # Façade métier d'édition +├── public/ +│ ├── manifest.webmanifest # Manifest PWA +│ └── icons/ # icônes 192 / 512 +├── Dockerfile # Image multi-stage (deps/builder/runner) +├── docker-compose.yml # Service cv-maker, healthcheck +├── .dockerignore # Exclusions de contexte build +├── serwist.config.mjs # Config service worker (mode configurator) +├── next.config.ts # output: "standalone" +├── tsconfig.json # strict +├── GOAL.md # Cahier des charges source +└── README.md # Ce document +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Modèle de données -## Deploy on Vercel +Contenu stocké dans **une seule clé localStorage** : `cv-maker:v1` (JSON). -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +```ts +interface CVCareerData { + version: 1; + accroche: string; // onglet 1 — message d'accroche + etatCivil: string; // onglet 2 — état civil + contacts + site + GitHub + formations: string; // onglet 3 — formations + softSkills: string; // onglet 4 — soft skills + centresInteret: string; // onglet 5 — centres d'intérêt + experiences: Experience[]; +} -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +interface Experience { + id: string; // crypto.randomUUID() — identifiant stable + titre: string; // titre affiché (liste + section CV) + contenu: string; // corps en Markdown + createdAt: number; + updatedAt: number; +} +``` + +- Les 5 zones sont du **Markdown brut** (le HTML rendu n'est jamais stocké). +- Les expériences sont affichées **dans l'ordre de saisie** : la 1ʳᵉ ajoutée en haut de la section « Expériences professionnelles », la suivante en dessous, etc. (la 1ʳᵉ porte le numéro 1). +- Sauvegarde invalide/corrompue → état par défaut, sans crash. + +## Fonctionnement clé + +### Rendu Markdown sécurisé + +`lib/markdown.ts` — toute injection HTML passe par ici : + +```ts +const rawHtml = marked.parse(source, { async: false }) as string; +return DOMPurify.sanitize(rawHtml); +``` + +`marked` génère le HTML ; **`DOMPurify` neutralise** tout script/événement URL dangereuse avant le `dangerouslySetInnerHTML`. Aucun HTML brut saisi n'est injecté directement. + +### Persistance locale + +`useLocalStorage` synchronise l'état React avec localStorage **à chaque changement** (via `useEffect`) ; l'enregistrement est silencieux (aucun indicateur affiché). `useCVData` expose la façade d'édition (`setText`, `addExperience`, `updateExperience`, `removeExperience`, `resetCV`, `importCV`), et la barre d'application permet l'**export/import JSON** (`cv-maker.json`) pour partager un CV entre appareils. + +### Gestion des expériences + +- Bouton **+** : nouvelle expérience (formulaire titre + contenu). +- Liste numérotée avec **Modifier** / **×** (suppression confirmée). +- Identifiants UUID : jamais d'index comme identifiant (robustesse lors des suppressions). + +### Impression PDF + +- Bouton « Imprimer / PDF » → `window.print()`. +- CSS `@media print` dans `app/globals.css` : `@page { size: A4; margin: 0 }`, panneau de saisie et toolbar masqués, seule la feuille A4 est imprimée. + +### PWA + +- **Mode « configurator »** (`@serwist/next` + `@serwist/cli`, compatible Turbopack) : `serwist.config.mjs` décrit le service worker, compilé par `pnpm build` après Next.js (`next build && serwist build`). +- `public/sw.js` pré-cache les routes `/`, `/documentation`, `/mentions-legales` et tous les assets (787 kB, 20 URL). +- En développement le service worker est **désactivé** (`SerwistProvider disable`) ; utiliser `pnpm dev:sw` pour le reconstruire à chaud. +- `public/manifest.webmanifest` + icônes 192/512 → installable, fonctionne hors-ligne (les données du CV restent dans localStorage). + +### Dockerisation + +L'image est construite en **3 étapes** (`Dockerfile` multi-stage) : + +| Étape | Image | Contenu | +| --- | --- | --- | +| `deps` | `node:20-alpine` | Installation déterministe des dépendances (`pnpm install --frozen-lockfile`, incl. `pnpm-workspace.yaml`) | +| `builder` | `node:20-alpine` | Compilation (`pnpm build`, sortie `standalone` + `sw.js`) | +| `runner` | `node:20-alpine` | Exécution uniquement (`node server.js`, image ≈ 60 Mo) | + +**Build et lancement :** + +```bash +docker build -t cv-maker:latest . +docker run --rm -p 3001:3000 cv-maker:latest # → http://localhost:3001 +``` + +> Si le port 3000 est déjà occupé en local, mapper un autre port hôte (`-p 3001:3000`). + +**Avec Docker Compose :** + +```bash +docker compose up -d +docker compose down +``` + +**Publication sur Docker Hub :** + +```bash +docker login +docker tag cv-maker:latest /cv-maker:latest +docker push /cv-maker:latest +``` + +Voir la [documentation détaillée Docker](./docs/conception/08-deploiement-docker.md) pour le contenu complet des fichiers (`Dockerfile`, `docker-compose.yml`, `.dockerignore`) et la recette de validation. + +## Conception détaillée + +Le dossier de conception complet (analyse fonctionnelle, architecture, modèle de données, UI, plan de réalisation pas-à-pas, sécurité/RGPD, plan de tests) se trouve dans : + +``` +docs/conception/ +├── README.md → sommaire et ordre de lecture +├── 01-analyse-fonctionnelle.md +├── 02-architecture-technique.md +├── 03-modele-donnees.md +├── 04-specifications-ui.md +├── 05-etapes-realisation.md +├── 06-securite-rgpd.md +├── 07-plan-tests.md +└── 08-deploiement-docker.md +``` + +## Limitations connues + +- **Desktop first** : tablette/mobile = niveau « utilisable », non optimisé. +- **One page A4 à l'impression** : feuille de preview à hauteur flexible ; un contenu très long peut générer plusieurs pages PDF. +- **Pas de tests automatisés** (v1) : recette manuelle documentée dans `docs/conception/07-plan-tests.md` ; tests Vitest prévus en v2. +- **Pas de drag & drop** de réorganisation des expériences (évolution prévue). \ No newline at end of file diff --git a/app/documentation/page.tsx b/app/documentation/page.tsx new file mode 100644 index 0000000..33396dd --- /dev/null +++ b/app/documentation/page.tsx @@ -0,0 +1,115 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Documentation — CV-Maker", + description: "Guide de prise en main de CV-Maker et syntaxe Markdown supportée.", +}; + +export default function DocumentationPage() { + return ( +
+ + ← Retour à l'éditeur + + +

Documentation

+ +
+

Prise en main

+
    +
  1. + Onglets 1 à 5 — sélectionnez une zone de saisie (accroche, + état civil + contacts, formations, soft skills, centres d'intérêt). + Un seul onglet est actif à la fois ; chaque zone conserve son texte. +
  2. +
  3. + Saisie Markdown — le texte est écrit en Markdown dans le + panneau gauche et rendu en HTML en temps réel dans la feuille A4 à droite. +
  4. +
  5. + Expériences professionnelles — bouton « Ajouter » pour créer + une expérience (titre + contenu). La liste est numérotée ; chaque entrée peut + être modifiée ou supprimée. L'ordre de saisie détermine l'affichage : la + première expérience ajoutée apparaît en haut de la section « Expériences + professionnelles » de la feuille, les suivantes en dessous. +
  6. +
  7. + Sauvegarde automatique — chaque frappe est enregistrée dans le + navigateur (localStorage, clé cv-maker:v1). Rechargez la page : + vos données sont restaurées. Aucune donnée n'est envoyée sur Internet. +
  8. +
  9. + Impression / PDF — le bouton « Imprimer / PDF » ouvre + l'aperçu d'impression du navigateur. Enregistrez en PDF : seule la + feuille A4 est exportée, sans les panneaux d'édition. +
  10. +
  11. + Export / import JSON — le bouton « Exporter JSON » + télécharge votre CV au format JSON (fichier cv-maker.json). + Sur un autre appareil, « Importer JSON » charge ce fichier et remplace + le contenu actuel (après confirmation). C'est le moyen de partager ou + de sauvegarder un CV entre plusieurs navigateurs. +
  12. +
  13. + Réinitialiser — le bouton « Réinitialiser » (après + confirmation) vide toutes les zones et la liste des expériences. +
  14. +
+
+ +
+

Syntaxe Markdown supportée

+

+ Les 5 zones et le contenu des expériences sont rendus en Markdown (GFM). +

+
+ + + + + + + + + {[ + ["Titre niveau 3", "### Développeur Full-Stack"], + ["Gras", "**Jean Dupont**"], + ["Italique", "*Contact*"], + ["Liste à puces", "- Compétence 1\n- Compétence 2"], + ["Liste numérotée", "1. Diplôme 1\n2. Diplôme 2"], + ["Lien", "[GitHub](https://github.com/user)"], + ["Lien brut", ""], + ["Code en ligne", "`npm run dev`"], + ["Bloc de code", "```\nconst x = 42;\n```"], + ["Citation", "> Citation d'accroche"], + ["Séparateur", "---"], + ].map(([label, syntax]) => ( + + + + + ))} + +
ÉlémentSyntaxe
{label} + {syntax} +
+
+
+ +
+

Conseil : structurer son CV

+

+ Utilisez l'onglet 1 pour le nom et le poste visé, l'onglet 2 pour vos + coordonnées (e-mail, téléphone, site web, GitHub), l'onglet 3 pour vos + formations, l'onglet 4 pour vos soft skills et l'onglet 5 pour vos + centres d'intérêt. Complétez avec vos expériences professionnelles, de la plus + récente à la plus ancienne. +

+
+
+ ); +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index a2dc41e..d8554ef 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,136 @@ @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +@theme { + --color-surface: #ffffff; + --color-paper: #f1f5f9; + --color-ink: #0f172a; + --color-muted: #64748b; + --color-accent: #2563eb; + --color-danger: #dc2626; + --color-border: #e2e8f0; + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, monospace; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background: var(--color-paper); + color: var(--color-ink); } + +/* Tailwind v4 ne force plus cursor:pointer sur les boutons ; on le restaure */ +/* (le curseur « not-allowed » des boutons désactivés reste prioritaire) */ +button:not(:disabled), +[role="button"]:not(:disabled) { + cursor: pointer; +} + +/* ── Feuille A4 (preview) ─────────────────────────────────────── */ +.cv-sheet { + width: 210mm; + max-width: 100%; + min-height: 297mm; + background: var(--color-surface); + box-shadow: 0 10px 30px rgb(15 23 42 / 0.15); + border: 1px solid var(--color-border); + padding: 16mm; +} + +.cv-sheet-body { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 12mm; +} + +/* ── Rendu Markdown du CV ─────────────────────────────────────── */ +.cv-md { + line-height: 1.45; +} + +.cv-md p { + margin: 0.25em 0; +} + +.cv-md h1, +.cv-md h2, +.cv-md h3, +.cv-md h4 { + font-weight: 700; + line-height: 1.2; + margin: 0.35em 0 0.25em; +} + +.cv-md h1 { + font-size: 1.1rem; +} +.cv-md h2 { + font-size: 1rem; +} +.cv-md h3 { + font-size: 0.9rem; +} +.cv-md h4 { + font-size: 0.85rem; +} + +.cv-md ul, +.cv-md ol { + margin: 0.25em 0; + padding-left: 1.2em; +} + +.cv-md li { + margin: 0.1em 0; +} + +.cv-md a { + color: var(--color-accent); + text-decoration: underline; +} + +.cv-md code { + font-family: var(--font-mono); + font-size: 0.9em; + background: var(--color-paper); + padding: 0.1em 0.3em; + border-radius: 0.25em; +} + +.cv-md pre { + background: var(--color-paper); + padding: 0.6em; + border-radius: 0.375em; + overflow-x: auto; + white-space: pre-wrap; +} + +.cv-md blockquote { + border-left: 3px solid var(--color-border); + padding-left: 0.75em; + margin: 0.25em 0; + color: var(--color-muted); +} + +/* ── Impression PDF ───────────────────────────────────────────── */ +@page { + size: A4; + margin: 0; +} + +@media print { + body { + background: #ffffff; + } + + .print-hidden { + display: none !important; + } + + .cv-sheet { + box-shadow: none; + border: 0; + margin: 0; + width: auto; + height: auto; + padding: 16mm; + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 9852c15..0b79700 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,7 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { SerwistProvider } from "@serwist/next/react"; +import { Footer } from "@/components/ui/Footer"; import "./globals.css"; const geistSans = Geist({ @@ -13,17 +15,32 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "CV-Maker — Créez votre CV", + description: + "Créez votre CV en Markdown avec aperçu temps réel au format A4, 100 % local dans votre navigateur.", +}; + +export const viewport: Viewport = { + themeColor: "#2563eb", }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( - {children} + + +
+ {children} +
+
+
+ ); -} +} \ No newline at end of file diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..e75af5d --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,25 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "CV-Maker", + short_name: "CV-Maker", + description: "Créez votre CV en Markdown avec aperçu temps réel au format A4.", + start_url: "/", + display: "standalone", + background_color: "#f1f5f9", + theme_color: "#2563eb", + icons: [ + { + src: "/icons/icon-192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/icons/icon-512.png", + sizes: "512x512", + type: "image/png", + }, + ], + }; +} \ No newline at end of file diff --git a/app/mentions-legales/page.tsx b/app/mentions-legales/page.tsx new file mode 100644 index 0000000..1847431 --- /dev/null +++ b/app/mentions-legales/page.tsx @@ -0,0 +1,115 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Mentions légales — CV-Maker", + description: + "Mentions légales de l'application CV-Maker : éditeur, données traitées, stockage et droits.", +}; + +export default function MentionsLegalesPage() { + return ( +
+ + ← Retour à l'éditeur + + +

Mentions légales

+ +
+
+

Éditeur

+

+ L'utilisateur de l'application est le seul responsable et + destinataire des données saisies. Aucune entité tiers ne traite ces + données. +

+
+ +
+

Données traitées

+

+ Les zones de saisie (message d'accroche, état civil, contacts, + formations, soft skills, centres d'intérêt) et les expériences + professionnelles contiennent des informations que vous choisissez de + saisir : nom, prénom, adresse, e-mail, téléphone, site web, GitHub, + parcours professionnel et formations. +

+
+ +
+

Finalité

+

+ Les données sont utilisées exclusivement à la production d'un CV, + destiné à être imprimé ou exporté en PDF par l'utilisateur. +

+
+ +
+

Stockage

+

+ Les données sont stockées dans le{" "} + + localStorage + {" "} + du navigateur, sous la clé{" "} + + cv-maker:v1 + + . Aucune donnée n'est envoyée à un serveur. Le stockage est + entièrement local à votre navigateur. +

+
+ +
+

Durée de conservation

+

+ Les données sont conservées jusqu'à leur effacement par + l'utilisateur (via le bouton « Réinitialiser » dans + l'application, ou en vidant le cache de votre navigateur). +

+
+ +
+

+ Droits de la personne concernée +

+

+ En tant que seul responsable des données, vous disposez d'un accès + complet à l'ensemble des informations saisies. Vous pouvez les + modifier à tout moment depuis l'éditeur, ou les supprimer en + utilisant : +

+
    +
  • + Le bouton « Réinitialiser » dans la barre d'outils + (après confirmation). +
  • +
  • + Les outils de votre navigateur : paramètres → vie privée → vider les + données de ce site pour cette application. +
  • +
+
+ +
+

Sécurité

+

+ Le contenu Markdown saisi est assaini avant rendu via{" "} + DOMPurify pour neutraliser tout script ou contenu + dangereux (protection contre les attaques XSS). Aucune donnée + n'est transmise sur le réseau ; l'application fonctionne + entièrement hors ligne une fois chargée. +

+
+ +

+ Dernière mise à jour : 16 septembre 2026 +

+
+
+ ); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index c887311..c6e42c7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,69 +1,5 @@ -import Image from "next/image"; +import { CvWorkspace } from "@/components/cv/CvWorkspace"; -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); -} +export default function HomePage() { + return ; +} \ No newline at end of file diff --git a/app/sw.ts b/app/sw.ts new file mode 100644 index 0000000..6fa3767 --- /dev/null +++ b/app/sw.ts @@ -0,0 +1,24 @@ +/// +/// + +import { Serwist } from "serwist"; +import type { PrecacheEntry, SerwistGlobalConfig } from "serwist"; +import { defaultCache } from "@serwist/next/worker"; + +declare global { + interface WorkerGlobalScope extends SerwistGlobalConfig { + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; + } +} + +declare const self: ServiceWorkerGlobalScope; + +const serwist = new Serwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: defaultCache, +}); + +serwist.addEventListeners(); \ No newline at end of file diff --git a/components/cv/CvExperiencePreview.tsx b/components/cv/CvExperiencePreview.tsx new file mode 100644 index 0000000..d36c2ff --- /dev/null +++ b/components/cv/CvExperiencePreview.tsx @@ -0,0 +1,18 @@ +import type { Experience } from "@/lib/types/cv"; + +export interface CvExperienceRendered { + experience: Experience; + html: string; +} + +export function CvExperiencePreview({ experience, html }: CvExperienceRendered) { + return ( +
+

{experience.titre}

+
+
+ ); +} \ No newline at end of file diff --git a/components/cv/CvHeader.tsx b/components/cv/CvHeader.tsx new file mode 100644 index 0000000..b0e1228 --- /dev/null +++ b/components/cv/CvHeader.tsx @@ -0,0 +1,14 @@ +interface CvHeaderProps { + html: string; +} + +export function CvHeader({ html }: CvHeaderProps) { + return ( +
+
+
+ ); +} \ No newline at end of file diff --git a/components/cv/CvInputPanel.tsx b/components/cv/CvInputPanel.tsx new file mode 100644 index 0000000..28f37b9 --- /dev/null +++ b/components/cv/CvInputPanel.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState } from "react"; +import { CvTabs } from "@/components/cv/CvTabs"; +import { MarkdownTextarea } from "@/components/cv/MarkdownTextarea"; +import { ExperienceManager } from "@/components/cv/ExperienceManager"; +import { CV_TABS, type CVTabId, type CVCareerData } from "@/lib/types/cv"; + +const PLACEHOLDERS: Record = { + accroche: "**Jean Dupont**\nDéveloppeur Full-Stack passionné par le web…", + etatCivil: + "**Jean Dupont**\n- jean.dupont@email.fr\n- 06 12 34 56 78\n- [Site web](https://mon-site.fr)\n- [GitHub](https://github.com/jean-dupont)", + formations: + "### Master Informatique\nUniversité — 2020\n\n### Licence Informatique\nUniversité — 2018", + softSkills: + "- Esprit d'équipe\n- Autonomie\n- Curiosité technique", + centresInteret: + "- Randonnée\n- Lecture\n- Musique", +}; + +interface CvInputPanelProps { + data: CVCareerData; + onSetText: (tab: CVTabId, value: string) => void; + onAddExperience: (titre: string, contenu: string) => void; + onUpdateExperience: (id: string, titre: string, contenu: string) => void; + onRemoveExperience: (id: string) => void; +} + +export function CvInputPanel({ + data, + onSetText, + onAddExperience, + onUpdateExperience, + onRemoveExperience, +}: CvInputPanelProps) { + const [activeTab, setActiveTab] = useState("accroche"); + const meta = CV_TABS[activeTab]; + const textareaId = `textarea-${activeTab}`; + + return ( +
+
+

Contenu du CV

+

+ Saisissez votre CV en Markdown. Le rendu s'affiche en temps réel à droite. +

+
+ + + + onSetText(activeTab, text)} + placeholder={PLACEHOLDERS[activeTab]} + minHeightClass={activeTab === "accroche" ? "min-h-[120px]" : "min-h-[280px]"} + /> + + +
+ ); +} \ No newline at end of file diff --git a/components/cv/CvLeftColumn.tsx b/components/cv/CvLeftColumn.tsx new file mode 100644 index 0000000..79ceea1 --- /dev/null +++ b/components/cv/CvLeftColumn.tsx @@ -0,0 +1,24 @@ +import { CvSection } from "@/components/cv/CvSection"; + +interface CvLeftColumnProps { + etatCivilHtml: string; + formationsHtml: string; + softSkillsHtml: string; + centresInteretHtml: string; +} + +export function CvLeftColumn({ + etatCivilHtml, + formationsHtml, + softSkillsHtml, + centresInteretHtml, +}: CvLeftColumnProps) { + return ( + + ); +} \ No newline at end of file diff --git a/components/cv/CvPreview.tsx b/components/cv/CvPreview.tsx new file mode 100644 index 0000000..6e4feae --- /dev/null +++ b/components/cv/CvPreview.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useMemo } from "react"; +import { markdownToHtml } from "@/lib/markdown"; +import type { CVCareerData } from "@/lib/types/cv"; +import { CvHeader } from "@/components/cv/CvHeader"; +import { CvLeftColumn } from "@/components/cv/CvLeftColumn"; +import { CvRightColumn } from "@/components/cv/CvRightColumn"; +import type { CvExperienceRendered } from "@/components/cv/CvExperiencePreview"; + +export function CvPreview({ data }: { data: CVCareerData }) { + const accrocheHtml = useMemo(() => markdownToHtml(data.accroche), [data.accroche]); + const etatCivilHtml = useMemo(() => markdownToHtml(data.etatCivil), [data.etatCivil]); + const formationsHtml = useMemo(() => markdownToHtml(data.formations), [data.formations]); + const softSkillsHtml = useMemo(() => markdownToHtml(data.softSkills), [data.softSkills]); + const centresInteretHtml = useMemo( + () => markdownToHtml(data.centresInteret), + [data.centresInteret], + ); + const experiences = useMemo( + () => + data.experiences.map((experience) => ({ + experience, + html: markdownToHtml(experience.contenu), + })), + [data.experiences], + ); + + return ( +
+
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/components/cv/CvRightColumn.tsx b/components/cv/CvRightColumn.tsx new file mode 100644 index 0000000..2ad7038 --- /dev/null +++ b/components/cv/CvRightColumn.tsx @@ -0,0 +1,31 @@ +import { + CvExperiencePreview, + type CvExperienceRendered, +} from "@/components/cv/CvExperiencePreview"; + +interface CvRightColumnProps { + experiences: CvExperienceRendered[]; +} + +export function CvRightColumn({ experiences }: CvRightColumnProps) { + return ( +
+

+ Expériences professionnelles +

+ {experiences.length === 0 ? ( +

+ Ajoutez une expérience dans le panneau gauche +

+ ) : ( + experiences.map((item) => ( + + )) + )} +
+ ); +} \ No newline at end of file diff --git a/components/cv/CvSection.tsx b/components/cv/CvSection.tsx new file mode 100644 index 0000000..6045c55 --- /dev/null +++ b/components/cv/CvSection.tsx @@ -0,0 +1,19 @@ +interface CvSectionProps { + title: string; + html: string; +} + +export function CvSection({ title, html }: CvSectionProps) { + if (!html.trim()) return null; + return ( +
+

+ {title} +

+
+
+ ); +} \ No newline at end of file diff --git a/components/cv/CvTabs.tsx b/components/cv/CvTabs.tsx new file mode 100644 index 0000000..93d6c1c --- /dev/null +++ b/components/cv/CvTabs.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Tabs } from "@/components/ui/Tabs"; +import { CV_TABS, CV_TAB_ORDER, type CVTabId } from "@/lib/types/cv"; + +interface CvTabsProps { + activeTab: CVTabId; + onTabChange: (tab: CVTabId) => void; +} + +export function CvTabs({ activeTab, onTabChange }: CvTabsProps) { + const items = CV_TAB_ORDER.map((id) => ({ + id, + label: `${CV_TABS[id].numero} · ${CV_TABS[id].label}`, + })); + + return ; +} \ No newline at end of file diff --git a/components/cv/CvToolbar.tsx b/components/cv/CvToolbar.tsx new file mode 100644 index 0000000..e1e9ad2 --- /dev/null +++ b/components/cv/CvToolbar.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useRef, type ChangeEvent } from "react"; +import { Download, FileText, Printer, RotateCcw, Upload } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { normalizeCVCareerData } from "@/lib/storage"; +import type { CVCareerData } from "@/lib/types/cv"; + +interface CvToolbarProps { + onReset: () => void; + data: CVCareerData; + onImport: (value: CVCareerData) => void; +} + +export function CvToolbar({ onReset, data, onImport }: CvToolbarProps) { + const pathname = usePathname(); + const fileInputRef = useRef(null); + + function handleExport() { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "cv-maker.json"; + link.click(); + URL.revokeObjectURL(url); + } + + function handleImportFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const parsed: unknown = JSON.parse(String(reader.result)); + const value = normalizeCVCareerData(parsed); + if (value === null) { + window.alert("Fichier invalide : ce n'est pas un CV au format attendu."); + return; + } + if (window.confirm("Importer ce CV ? Le contenu actuel sera remplacé.")) { + onImport(value); + } + } catch { + window.alert("Fichier illisible : le JSON est invalide."); + } + }; + reader.readAsText(file); + } + + return ( +
+
+ + + + CV-Maker +
+ + +
+ ); +} \ No newline at end of file diff --git a/components/cv/CvWorkspace.tsx b/components/cv/CvWorkspace.tsx new file mode 100644 index 0000000..88b70c2 --- /dev/null +++ b/components/cv/CvWorkspace.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useCVData } from "@/lib/hooks/useCVData"; +import { CvToolbar } from "@/components/cv/CvToolbar"; +import { CvInputPanel } from "@/components/cv/CvInputPanel"; +import { CvPreview } from "@/components/cv/CvPreview"; + +export function CvWorkspace() { + const { + data, + setText, + addExperience, + updateExperience, + removeExperience, + resetCV, + importCV, + } = useCVData(); + + return ( +
+ +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/components/cv/ExperienceItem.tsx b/components/cv/ExperienceItem.tsx new file mode 100644 index 0000000..6534562 --- /dev/null +++ b/components/cv/ExperienceItem.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; +import { Pencil, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { IconButton } from "@/components/ui/IconButton"; +import type { Experience } from "@/lib/types/cv"; + +interface ExperienceItemProps { + index: number; + experience: Experience; + onEdit: () => void; + onRemove: () => void; +} + +export function ExperienceItem({ + index, + experience, + onEdit, + onRemove, +}: ExperienceItemProps) { + const [confirming, setConfirming] = useState(false); + + if (confirming) { + return ( +
  • + Supprimer cette expérience ? +
    + + +
    +
  • + ); + } + + return ( +
  • + + {index + 1}. + {experience.titre} + +
    + + + + setConfirming(true)} + > + + +
    +
  • + ); +} \ No newline at end of file diff --git a/components/cv/ExperienceManager.tsx b/components/cv/ExperienceManager.tsx new file mode 100644 index 0000000..7b27ed1 --- /dev/null +++ b/components/cv/ExperienceManager.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Plus, Save, X } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Textarea } from "@/components/ui/Textarea"; +import { ExperienceItem } from "@/components/cv/ExperienceItem"; +import type { Experience } from "@/lib/types/cv"; + +type ExperienceEditor = + | { mode: "closed" } + | { mode: "new" } + | { mode: "edit"; id: Experience["id"] }; + +interface ExperienceManagerProps { + experiences: Experience[]; + onAdd: (titre: string, contenu: string) => void; + onUpdate: (id: string, titre: string, contenu: string) => void; + onRemove: (id: string) => void; +} + +export function ExperienceManager({ + experiences, + onAdd, + onUpdate, + onRemove, +}: ExperienceManagerProps) { + const [editor, setEditor] = useState({ mode: "closed" }); + const [titre, setTitre] = useState(""); + const [contenu, setContenu] = useState(""); + + const isEditing = editor.mode !== "closed"; + const editingId = editor.mode === "edit" ? editor.id : null; + + function startNew() { + setEditor({ mode: "new" }); + setTitre(""); + setContenu(""); + } + + function startEdit(id: string) { + const experience = experiences.find((exp) => exp.id === id); + if (!experience) return; + setEditor({ mode: "edit", id }); + setTitre(experience.titre); + setContenu(experience.contenu); + } + + function close() { + setEditor({ mode: "closed" }); + setTitre(""); + setContenu(""); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const trimmedTitre = titre.trim(); + const title = trimmedTitre || "Expérience"; + if (editor.mode === "new") { + onAdd(title, contenu); + } else if (editor.mode === "edit") { + onUpdate(editor.id, title, contenu); + } + close(); + } + + return ( +
    +
    +

    + Expériences professionnelles +

    + +
    + + {isEditing && ( +
    +
    + + setTitre(event.target.value)} + placeholder="Développeur Full-Stack" + autoFocus + className="rounded-md border border-border px-3 py-2 text-sm text-ink placeholder:text-muted focus:outline-2 focus:outline-offset-2 focus:outline-accent" + /> +
    +
    + +