chore init
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
.DS_Store
|
||||||
@@ -39,3 +39,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# service worker (généré par @serwist/cli)
|
||||||
|
public/sw*
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
```bash
|
||||||
npm run dev
|
# 1. Installer les dépendances
|
||||||
# or
|
pnpm install
|
||||||
yarn dev
|
|
||||||
# or
|
# 2. Lancer en développement
|
||||||
pnpm dev
|
pnpm dev # → http://localhost:3000
|
||||||
# or
|
|
||||||
bun dev
|
# 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 <mon-user-dockerhub>/cv-maker:latest
|
||||||
|
docker push <mon-user-dockerhub>/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).
|
||||||
@@ -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 (
|
||||||
|
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm font-medium text-muted underline-offset-2 hover:text-accent hover:underline"
|
||||||
|
>
|
||||||
|
← Retour à l'éditeur
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 text-2xl font-bold text-ink">Documentation</h1>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="text-xl font-semibold text-ink">Prise en main</h2>
|
||||||
|
<ol className="mt-3 list-decimal space-y-2 pl-5 text-sm leading-relaxed text-ink">
|
||||||
|
<li>
|
||||||
|
<strong>Onglets 1 à 5</strong> — 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Saisie Markdown</strong> — le texte est écrit en Markdown dans le
|
||||||
|
panneau gauche et rendu en HTML en temps réel dans la feuille A4 à droite.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Expériences professionnelles</strong> — 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Sauvegarde automatique</strong> — chaque frappe est enregistrée dans le
|
||||||
|
navigateur (localStorage, clé <code>cv-maker:v1</code>). Rechargez la page :
|
||||||
|
vos données sont restaurées. Aucune donnée n'est envoyée sur Internet.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Impression / PDF</strong> — 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Export / import JSON</strong> — le bouton « Exporter JSON »
|
||||||
|
télécharge votre CV au format JSON (fichier <code>cv-maker.json</code>).
|
||||||
|
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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Réinitialiser</strong> — le bouton « Réinitialiser » (après
|
||||||
|
confirmation) vide toutes les zones et la liste des expériences.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-xl font-semibold text-ink">Syntaxe Markdown supportée</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-muted">
|
||||||
|
Les 5 zones et le contenu des expériences sont rendus en Markdown (GFM).
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 overflow-x-auto rounded-md border border-border bg-white">
|
||||||
|
<table className="w-full text-sm text-ink">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-paper text-left">
|
||||||
|
<th className="px-3 py-2 font-semibold">Élément</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Syntaxe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{[
|
||||||
|
["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", "<https://example.com>"],
|
||||||
|
["Code en ligne", "`npm run dev`"],
|
||||||
|
["Bloc de code", "```\nconst x = 42;\n```"],
|
||||||
|
["Citation", "> Citation d'accroche"],
|
||||||
|
["Séparateur", "---"],
|
||||||
|
].map(([label, syntax]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td className="px-3 py-2 font-medium">{label}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs whitespace-pre">
|
||||||
|
{syntax}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8 rounded-md border border-border bg-white p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-ink">Conseil : structurer son CV</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-ink">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,136 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
@theme {
|
||||||
--background: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--foreground: #171717;
|
--color-paper: #f1f5f9;
|
||||||
}
|
--color-ink: #0f172a;
|
||||||
|
--color-muted: #64748b;
|
||||||
@theme inline {
|
--color-accent: #2563eb;
|
||||||
--color-background: var(--background);
|
--color-danger: #dc2626;
|
||||||
--color-foreground: var(--foreground);
|
--color-border: #e2e8f0;
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--color-paper);
|
||||||
color: var(--foreground);
|
color: var(--color-ink);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { SerwistProvider } from "@serwist/next/react";
|
||||||
|
import { Footer } from "@/components/ui/Footer";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -13,17 +15,32 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "CV-Maker — Créez votre CV",
|
||||||
description: "Generated by create next app",
|
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<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="fr"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col bg-paper text-ink">
|
||||||
|
<SerwistProvider
|
||||||
|
swUrl="/sw.js"
|
||||||
|
disable={process.env.NODE_ENV === "development"}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</SerwistProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm font-medium text-muted underline-offset-2 hover:text-accent hover:underline"
|
||||||
|
>
|
||||||
|
← Retour à l'éditeur
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 text-2xl font-bold text-ink">Mentions légales</h1>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6 text-sm leading-relaxed text-ink">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-base font-semibold text-ink">Éditeur</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
L'utilisateur de l'application est le seul responsable et
|
||||||
|
destinataire des données saisies. Aucune entité tiers ne traite ces
|
||||||
|
données.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-base font-semibold text-ink">Données traitées</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-base font-semibold text-ink">Finalité</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
Les données sont utilisées exclusivement à la production d'un CV,
|
||||||
|
destiné à être imprimé ou exporté en PDF par l'utilisateur.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-base font-semibold text-ink">Stockage</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
Les données sont stockées dans le{" "}
|
||||||
|
<code className="rounded bg-paper px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
localStorage
|
||||||
|
</code>{" "}
|
||||||
|
du navigateur, sous la clé{" "}
|
||||||
|
<code className="rounded bg-paper px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
cv-maker:v1
|
||||||
|
</code>
|
||||||
|
. Aucune donnée n'est envoyée à un serveur. Le stockage est
|
||||||
|
entièrement local à votre navigateur.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-base font-semibold text-ink">Durée de conservation</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-base font-semibold text-ink">
|
||||||
|
Droits de la personne concernée
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
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 :
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
<li>
|
||||||
|
Le bouton <strong>« Réinitialiser »</strong> dans la barre d'outils
|
||||||
|
(après confirmation).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Les outils de votre navigateur : paramètres → vie privée → vider les
|
||||||
|
données de ce site pour cette application.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-base font-semibold text-ink">Sécurité</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
Le contenu Markdown saisi est assaini avant rendu via{" "}
|
||||||
|
<strong>DOMPurify</strong> 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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Dernière mise à jour : 16 septembre 2026
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,69 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { CvWorkspace } from "@/components/cv/CvWorkspace";
|
||||||
|
|
||||||
export default function Home() {
|
export default function HomePage() {
|
||||||
return (
|
return <CvWorkspace />;
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
}
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert h-5 w-[100px]"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the{" "}
|
|
||||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
|
||||||
page.tsx
|
|
||||||
</code>{" "}
|
|
||||||
file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert h-[14px] w-4"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={14}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/// <reference lib="esnext" />
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -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 (
|
||||||
|
<article className="mb-4">
|
||||||
|
<h4 className="text-[0.9rem] font-bold text-ink">{experience.titre}</h4>
|
||||||
|
<div
|
||||||
|
className="cv-md text-[0.8rem] leading-snug text-ink"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
interface CvHeaderProps {
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CvHeader({ html }: CvHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="mb-4 border-b border-border pb-3">
|
||||||
|
<div
|
||||||
|
className="cv-md text-ink"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<CVTabId, string> = {
|
||||||
|
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<CVTabId>("accroche");
|
||||||
|
const meta = CV_TABS[activeTab];
|
||||||
|
const textareaId = `textarea-${activeTab}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print-hidden flex flex-col gap-4 p-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-lg font-bold text-ink">Contenu du CV</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Saisissez votre CV en Markdown. Le rendu s'affiche en temps réel à droite.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<CvTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
|
<MarkdownTextarea
|
||||||
|
id={textareaId}
|
||||||
|
label={`Zone ${meta.numero} — ${meta.label}`}
|
||||||
|
value={data[activeTab]}
|
||||||
|
onChange={(text) => onSetText(activeTab, text)}
|
||||||
|
placeholder={PLACEHOLDERS[activeTab]}
|
||||||
|
minHeightClass={activeTab === "accroche" ? "min-h-[120px]" : "min-h-[280px]"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExperienceManager
|
||||||
|
experiences={data.experiences}
|
||||||
|
onAdd={onAddExperience}
|
||||||
|
onUpdate={onUpdateExperience}
|
||||||
|
onRemove={onRemoveExperience}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<aside>
|
||||||
|
<CvSection title="État civil" html={etatCivilHtml} />
|
||||||
|
<CvSection title="Formations" html={formationsHtml} />
|
||||||
|
<CvSection title="Soft skills" html={softSkillsHtml} />
|
||||||
|
<CvSection title="Centres d'intérêt" html={centresInteretHtml} />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<CvExperienceRendered[]>(
|
||||||
|
() =>
|
||||||
|
data.experiences.map((experience) => ({
|
||||||
|
experience,
|
||||||
|
html: markdownToHtml(experience.contenu),
|
||||||
|
})),
|
||||||
|
[data.experiences],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center overflow-x-auto p-6 print:block print:p-0 print:overflow-visible">
|
||||||
|
<div className="cv-sheet">
|
||||||
|
<CvHeader html={accrocheHtml} />
|
||||||
|
<div className="cv-sheet-body">
|
||||||
|
<CvLeftColumn
|
||||||
|
etatCivilHtml={etatCivilHtml}
|
||||||
|
formationsHtml={formationsHtml}
|
||||||
|
softSkillsHtml={softSkillsHtml}
|
||||||
|
centresInteretHtml={centresInteretHtml}
|
||||||
|
/>
|
||||||
|
<CvRightColumn experiences={experiences} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
CvExperiencePreview,
|
||||||
|
type CvExperienceRendered,
|
||||||
|
} from "@/components/cv/CvExperiencePreview";
|
||||||
|
|
||||||
|
interface CvRightColumnProps {
|
||||||
|
experiences: CvExperienceRendered[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CvRightColumn({ experiences }: CvRightColumnProps) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h2 className="mb-3 text-[0.75rem] font-bold uppercase tracking-wide text-muted">
|
||||||
|
Expériences professionnelles
|
||||||
|
</h2>
|
||||||
|
{experiences.length === 0 ? (
|
||||||
|
<p className="print-hidden text-sm text-muted">
|
||||||
|
Ajoutez une expérience dans le panneau gauche
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
experiences.map((item) => (
|
||||||
|
<CvExperiencePreview
|
||||||
|
key={item.experience.id}
|
||||||
|
experience={item.experience}
|
||||||
|
html={item.html}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
interface CvSectionProps {
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CvSection({ title, html }: CvSectionProps) {
|
||||||
|
if (!html.trim()) return null;
|
||||||
|
return (
|
||||||
|
<section className="mb-4">
|
||||||
|
<h3 className="mb-1 text-[0.75rem] font-bold uppercase tracking-wide text-muted">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className="cv-md text-[0.8rem] leading-snug text-ink"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <Tabs items={items} activeId={activeTab} onChange={onTabChange} />;
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>(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<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<header className="print-hidden sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 border-b border-border bg-white px-6 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-accent text-white">
|
||||||
|
<FileText size={18} aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-bold text-ink">CV-Maker</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => window.print()} title="Imprimer ou exporter en PDF">
|
||||||
|
<Printer size={14} aria-hidden />
|
||||||
|
Imprimer / PDF
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportFile}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
title="Importer un CV depuis un fichier JSON"
|
||||||
|
>
|
||||||
|
<Upload size={14} aria-hidden />
|
||||||
|
Importer JSON
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleExport} title="Télécharger le CV au format JSON">
|
||||||
|
<Download size={14} aria-hidden />
|
||||||
|
Exporter JSON
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="Réinitialiser le CV"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm("Réinitialiser tout le contenu du CV ?")) onReset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} aria-hidden />
|
||||||
|
Réinitialiser
|
||||||
|
</Button>
|
||||||
|
{[
|
||||||
|
{ href: "/documentation", label: "Documentation" },
|
||||||
|
].map(({ href, label: linkLabel }) => {
|
||||||
|
const active = pathname === href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
|
||||||
|
active ? "bg-accent text-white" : "text-muted hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{linkLabel}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-paper text-ink print:min-h-0">
|
||||||
|
<CvToolbar onReset={resetCV} data={data} onImport={importCV} />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] print:block">
|
||||||
|
<CvInputPanel
|
||||||
|
data={data}
|
||||||
|
onSetText={setText}
|
||||||
|
onAddExperience={addExperience}
|
||||||
|
onUpdateExperience={updateExperience}
|
||||||
|
onRemoveExperience={removeExperience}
|
||||||
|
/>
|
||||||
|
<CvPreview data={data} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<li className="flex items-center justify-between gap-2 rounded-md border border-danger/40 bg-red-50 px-3 py-2">
|
||||||
|
<span className="text-sm text-ink">Supprimer cette expérience ?</span>
|
||||||
|
<div className="flex shrink-0 gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Oui
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)}>
|
||||||
|
Non
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between gap-2 rounded-md border border-border bg-white px-3 py-2">
|
||||||
|
<span className="truncate text-sm text-ink">
|
||||||
|
<span className="mr-1.5 text-muted">{index + 1}.</span>
|
||||||
|
{experience.titre}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 gap-0.5">
|
||||||
|
<IconButton aria-label="Modifier l'expérience" onClick={onEdit}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
danger
|
||||||
|
aria-label="Supprimer l'expérience"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ExperienceEditor>({ 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 (
|
||||||
|
<section aria-labelledby="experiences-title" className="mt-6 border-t border-border pt-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 id="experiences-title" className="text-sm font-semibold text-ink">
|
||||||
|
Expériences professionnelles
|
||||||
|
</h2>
|
||||||
|
<Button size="sm" onClick={startNew}>
|
||||||
|
<Plus size={14} aria-hidden />
|
||||||
|
Ajouter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="mt-3 flex flex-col gap-3 rounded-md border border-accent bg-white p-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="experience-titre" className="text-xs font-medium text-ink">
|
||||||
|
Titre
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="experience-titre"
|
||||||
|
type="text"
|
||||||
|
value={titre}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="experience-contenu" className="text-xs font-medium text-ink">
|
||||||
|
Contenu (Markdown)
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="experience-contenu"
|
||||||
|
value={contenu}
|
||||||
|
onChange={(event) => setContenu(event.target.value)}
|
||||||
|
placeholder="- Stack : React, Next.js - Missions : …"
|
||||||
|
className="min-h-[120px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
<Save size={14} aria-hidden />
|
||||||
|
{editingId ? "Enregistrer" : "Ajouter"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" type="button" onClick={close}>
|
||||||
|
<X size={14} aria-hidden />
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ol className="mt-3 flex flex-col gap-1.5">
|
||||||
|
{experiences.map((experience, index) => (
|
||||||
|
<ExperienceItem
|
||||||
|
key={experience.id}
|
||||||
|
index={index}
|
||||||
|
experience={experience}
|
||||||
|
onEdit={() => startEdit(experience.id)}
|
||||||
|
onRemove={() => onRemove(experience.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{experiences.length === 0 && (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Aucune expérience. Utilisez le bouton « Ajouter » pour commencer.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Textarea } from "@/components/ui/Textarea";
|
||||||
|
|
||||||
|
interface MarkdownTextareaProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (text: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
minHeightClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownTextarea({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
minHeightClass,
|
||||||
|
}: MarkdownTextareaProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<label htmlFor={id} className="text-sm font-semibold text-ink">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<Link
|
||||||
|
href="/documentation"
|
||||||
|
className="text-xs text-muted underline-offset-2 hover:text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Syntaxe Markdown
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`tab-${id.replace("textarea-", "")}`}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={minHeightClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type ButtonVariant = "primary" | "ghost" | "danger";
|
||||||
|
type ButtonSize = "sm" | "md";
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<ButtonVariant, string> = {
|
||||||
|
primary: "bg-accent text-white hover:bg-accent/90",
|
||||||
|
ghost: "border border-border bg-white text-ink hover:bg-paper",
|
||||||
|
danger: "bg-danger text-white hover:bg-danger/90",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses: Record<ButtonSize, string> = {
|
||||||
|
sm: "rounded-sm px-2 py-1 text-xs",
|
||||||
|
md: "rounded-md px-3 py-2 text-sm",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
className = "",
|
||||||
|
type = "button",
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
className={`inline-flex items-center justify-center gap-1.5 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="print-hidden border-t border-border bg-white px-6 py-4 text-center text-sm text-muted">
|
||||||
|
<span>© {year} CV-Maker</span>
|
||||||
|
<span aria-hidden className="mx-2 text-border">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href="/mentions-legales"
|
||||||
|
className="font-medium underline-offset-2 hover:text-accent hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
Mentions légales
|
||||||
|
</Link>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
"aria-label": string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
danger = false,
|
||||||
|
type = "button",
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={`inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:opacity-50 ${
|
||||||
|
danger ? "text-danger hover:bg-danger/10" : "text-muted hover:bg-paper hover:text-ink"
|
||||||
|
} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
export interface TabItem<T extends string> {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabsProps<T extends string> {
|
||||||
|
items: TabItem<T>[];
|
||||||
|
activeId: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs<T extends string>({ items, activeId, onChange }: TabsProps<T>) {
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
||||||
|
const currentIndex = items.findIndex((item) => item.id === activeId);
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
const next = items.at((currentIndex + 1) % items.length);
|
||||||
|
if (next) onChange(next.id);
|
||||||
|
} else if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
const prev = items.at((currentIndex - 1 + items.length) % items.length);
|
||||||
|
if (prev) onChange(prev.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="tablist" aria-label="Sections du CV" className="flex flex-wrap gap-1">
|
||||||
|
{items.map((item) => {
|
||||||
|
const selected = item.id === activeId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
role="tab"
|
||||||
|
id={`tab-${item.id}`}
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-controls={`panel-${item.id}`}
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
|
||||||
|
selected
|
||||||
|
? "bg-accent text-white"
|
||||||
|
: "border border-border bg-white text-muted hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { TextareaHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export function Textarea({ className = "", ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={`w-full resize-y rounded-md border border-border bg-white p-3 font-mono text-sm text-ink placeholder:text-muted focus:outline-2 focus:outline-offset-2 focus:outline-accent ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
cv-maker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: cv-maker:local # nom local ; remplacé par <mon-user-dockerhub>/cv-maker:tag au push
|
||||||
|
container_name: cv-maker
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
- PORT=3000
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# 01 — Analyse fonctionnelle
|
||||||
|
|
||||||
|
## 1. Contexte et objectif
|
||||||
|
|
||||||
|
CV-Maker est une application web **sans backend** permettant à un utilisateur de créer son CV rapidement, en saisissant le contenu en **Markdown** dans des zones de saisie et en visualisant le résultat en **temps réel** sur une feuille au **format A4**.
|
||||||
|
|
||||||
|
- **Utilisateur cible** : une personne unique, sur poste de travail (desktop).
|
||||||
|
- **Aucune donnée n'est envoyée à un serveur** : tout est stocké dans le navigateur (localStorage).
|
||||||
|
- **Notation (ECF CDA)** : le projet sert de support pédagogique et doit démontrer : maîtrise de Next.js/React, composants réutilisables, gestion d'état, persistance locale, sécurité de rendu, capacité de documentation.
|
||||||
|
|
||||||
|
## 2. Périmètre fonctionnel
|
||||||
|
|
||||||
|
### 2.1 Fonctions principales (issue du GOAL.md)
|
||||||
|
|
||||||
|
| Réf. | Fonction | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F1 | Éditeur temps réel | Saisie Markdown dans le panneau gauche → rendu HTML temps réel dans le panneau droit |
|
||||||
|
| F2 | Preview A4 | Panneau droit matérialisé en feuille A4 (210 × 297 mm), impeccable à l'impression |
|
||||||
|
| F3 | Persistance | Sauvegarde automatique dans localStorage à chaque frappe ; données restaurées au rechargement |
|
||||||
|
| F4 | Impression PDF | Bouton d'impression → export PDF via la boîte de dialogue du navigateur |
|
||||||
|
| F5 | Gestion des expériences | Ajout / modification / suppression d'expériences professionnelles |
|
||||||
|
| F6 | Page documentation | Page d'aide expliquant l'utilisation et la syntaxe Markdown |
|
||||||
|
| F7 | Page Mentions légales | Page d'information « mentions légales / vie privée » |
|
||||||
|
| F8 | PWA | Application web installable hors-ligne (nice to have côté objectif, prévue au cahier des charges) |
|
||||||
|
|
||||||
|
### 2.2 Responsive / device
|
||||||
|
|
||||||
|
- **Desktop first** : l'expérience est optimisée pour un écran large.
|
||||||
|
- **Tablette et mobile : nice to have** ; minimum acceptable = interface utilisable sans casse majeure. Pas de refonte dédiée.
|
||||||
|
|
||||||
|
## 3. Maquette fonctionnelle de l'écran principal `/`
|
||||||
|
|
||||||
|
### 3.1 Agencement général
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Barre d'application (logo, titre, boutons : imprimer, doc) │
|
||||||
|
├───────────────────────────────┬──────────────────────────────┤
|
||||||
|
│ Panneau GAUCHE │ Panneau DROIT │
|
||||||
|
│ 1/3 de la largeur │ 2/3 de la largeur │
|
||||||
|
│ │ │
|
||||||
|
│ [Tabs 1 ▸ 2 ▸ 3 ▸ 4 ▸ 5] │ ┌────────────────────────┐ │
|
||||||
|
│ │ │ Feuille A4 │ │
|
||||||
|
│ [MarkdownTextarea : │ │ ┌────────────────────┐ │ │
|
||||||
|
│ zone active] │ │ │ Header accroche │ │ │
|
||||||
|
│ │ │ └────────────────────┘ │ │
|
||||||
|
│ ── SÉPARATEUR ── │ │ ┌───────┬────────────┐ │ │
|
||||||
|
│ [Zone EXPÉRIENCES] │ │ │ 1/3 │ 2/3 │ │ │
|
||||||
|
│ [+ Ajouter] │ │ │ Etat │ Expériences│ │ │
|
||||||
|
│ • 1. Titre exp [Modif] [×] │ │ │ civil │ profession.│ │ │
|
||||||
|
│ • 2. Titre exp [Modif] [×] │ │ │ Forma-│ ... │ │ │
|
||||||
|
│ │ │ │ tions │ │ │ │
|
||||||
|
│ │ │ │ Soft │ │ │ │
|
||||||
|
│ │ │ │ skills│ │ │ │
|
||||||
|
│ │ │ │ Centr.│ │ │ │
|
||||||
|
│ │ │ │ intérêt│ │ │ │
|
||||||
|
│ │ │ └───────┴────────────┘ │ │
|
||||||
|
│ │ └────────────────────────┘ │
|
||||||
|
└───────────────────────────────┴──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Panneau latéral gauche — saisie
|
||||||
|
|
||||||
|
#### Structure
|
||||||
|
|
||||||
|
- **Zones 1 à 5** gérées par un **système d'onglets (tabs)** numérotés de 1 à 5. Un seul onglet actif à la fois ; chaque onglet affiche une `textarea` Markdown dédiée.
|
||||||
|
|
||||||
|
| N° onglet | Zone | Champ(s) du CV associé(s) | Préfixe de navigation |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | Message d'accroche | En-tête du CV (sous le nom/poste) | |
|
||||||
|
| 2 | État civil + contacts | Nom, prénom, adresse, e-mail, téléphone, **site web**, **lien GitHub** | |
|
||||||
|
| 3 | Formations | Liste des formations / diplômes | |
|
||||||
|
| 4 | Soft skills | Compétences comportementales | |
|
||||||
|
| 5 | Centres d'intérêt | Loisirs, activités | |
|
||||||
|
|
||||||
|
- **Zone « Expériences »** : fonctionne **hors du système d'onglets**. Elle est toujours visible sous la zone d'onglets. Elle sert à saisir **toutes les expériences professionnelles**, une par une.
|
||||||
|
|
||||||
|
#### Interactions zone expériences
|
||||||
|
|
||||||
|
1. **Bouton `+`** : ajoute une nouvelle expérience.
|
||||||
|
2. Une **liste numérotée** s'affiche sous la zone de saisie : chaque entrée affiche le titre de l'expérience avec deux actions :
|
||||||
|
- **`Modifier`** (bouton édition) : recharge le contenu de l'expérience dans la zone de saisie pour modification ;
|
||||||
|
- **`×`** (bouton suppression) : supprime l'expérience (avec confirmation).
|
||||||
|
3. Toute nouvelle expérience s'ajoute **en bas** de la section « Expériences professionnelles » du CV : l'ordre d'affichage suit **l'ordre de saisie** (la 1ʳᵉ expérience ajoutée reste en haut, numérotée 1).
|
||||||
|
4. La liste est numérotée dans l'ordre d'affichage (1, 2, 3… correspondant à la position dans le CV).
|
||||||
|
|
||||||
|
### 3.3 Panneau latéral droit — preview A4
|
||||||
|
|
||||||
|
- Une seule feuille blanche au **format A4** (210 × 297 mm), avec une marge définie, représentant 1 page de CV.
|
||||||
|
- **Header** (toute la largeur de la feuille) : rendu de l'**onglet 1 — message d'accroche**.
|
||||||
|
- **Colonne gauche (1/3 de la largeur utile)** : rendus des sections correspondant aux **onglets 2 à 5** :
|
||||||
|
- État civil (zone 2) : contacts, site web, GitHub ;
|
||||||
|
- Formations (zone 3) ;
|
||||||
|
- Soft skills (zone 4) ;
|
||||||
|
- Centres d'intérêt (zone 5).
|
||||||
|
- **Colonne droite (2/3 de la largeur utile)** : rendu des **expériences professionnelles**, sous le titre fixe **« Expériences professionnelles »**.
|
||||||
|
- Le rendu est **temps réel** : toute frappe dans une zone de saisie met à jour la zone cible correspondante instantanément.
|
||||||
|
|
||||||
|
### 3.4 Barre d'application (en-tête de l'application)
|
||||||
|
|
||||||
|
Éléments minimaux :
|
||||||
|
- Nom applicatif « CV-Maker » / logo ;
|
||||||
|
- Bouton **Imprimer / PDF** (déclenche `window.print()`) ;
|
||||||
|
- Boutons **Importer JSON** puis **Exporter JSON** (partage de CV entre appareils) ;
|
||||||
|
- Bouton **Réinitialiser** (après confirmation) ;
|
||||||
|
- Lien vers la page **Documentation** ;
|
||||||
|
- Pied de page : lien vers la page **Mentions légales** ;
|
||||||
|
- Sauvegarde automatique silencieuse (aucun indicateur de statut — inutile vu l'enregistrement à chaque frappe).
|
||||||
|
|
||||||
|
## 4. Pages
|
||||||
|
|
||||||
|
| Route | Rôle |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | Éditeur principal (2 colonnes) |
|
||||||
|
| `/documentation` | Aide : prise en main + rappel syntaxe Markdown supportée |
|
||||||
|
| `/mentions-legales` | Informations vie privée / traitement des données (localOnly) |
|
||||||
|
|
||||||
|
## 5. Contraintes transverses
|
||||||
|
|
||||||
|
- **Desktop first** : design optimisé ≥ 1024 px ; grille responsive en repli simple (colonnes empilées) sous 768 px.
|
||||||
|
- **No backend** : aucune API, aucun appel réseau. Les pages `/documentation` et `/mentions-legales` sont statiques.
|
||||||
|
- **Rendu Markdown sécurisé** : HTML produit par `marked` puis **assaini par DOMPurify** avant injection.
|
||||||
|
- **Performance de rendu** : rendu Markdown synchrone (contenus de taille « CV »), tolérable en temps réel.
|
||||||
|
- **Persistance robuste** : écriture localStorage à chaque frappe de la zone active, lecture au chargement, gestion des erreurs (quota, navigateur bloqué, JSON corrompu).
|
||||||
|
|
||||||
|
## 6. Règles de gestion (résumé formalisé)
|
||||||
|
|
||||||
|
| Règle | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| RG1 | Toute modification d'un champ (textarea ou liste expériences) est persistée immédiatement dans localStorage. |
|
||||||
|
| RG2 | Au chargement, si une sauvegarde existe, l'état de l'application est restauré (toutes les zones). |
|
||||||
|
| RG3 | Si aucune sauvegarde ou sauvegarde corrompue/non compatible → données par défaut (zones vides), sauvegarde initiale créée. |
|
||||||
|
| RG4 | Un seul onglet actif ; la navigation entre onglets ne modifie pas les contenus des autres onglets. |
|
||||||
|
| RG5 | L'ajout d'une expérience place cette expérience en tête de la preview « Expériences professionnelles ». |
|
||||||
|
| RG6 | Chaque expérience possède un identifiant unique stable (UUID) — jamais l'index de liste. |
|
||||||
|
| RG7 | L'impression n'interrompt pas la persistance ; le rendu imprimé = rendu de la feuille A4 uniquement (les panneaux d'édition sont masqués en impression). |
|
||||||
|
| RG8 | Le HTML saisi (via Markdown brut) est neutralisé s'il contient du JavaScript/sans sécurisation → DOMPurify obligatoire avant `dangerouslySetInnerHTML`. |
|
||||||
|
|
||||||
|
## 7. Hors périmètre (v1)
|
||||||
|
|
||||||
|
- Multi-utilisateurs / comptes / cloud ;
|
||||||
|
- Édition collaborative ;
|
||||||
|
- Drag & drop de réorganisation des expériences (prévu comme évolution) ;
|
||||||
|
- Export multi-formats autres que PDF (Docx, JSON…) ;
|
||||||
|
- Tests automatisés (prévus en v2, cf. `07-plan-tests.md` pour la recette manuelle).
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# 02 — Architecture technique
|
||||||
|
|
||||||
|
## 1. Stack technique
|
||||||
|
|
||||||
|
| Couche | Choix | Version cible | Justification |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Framework | **Next.js** (App Router) | 15.x (React 19) | Standard actuel ; SSR/SSG natif, metadata, conventions de structure |
|
||||||
|
| Langage | **TypeScript** | strict mode | Typage des modèles de données, cohérence du contrat d'état, qualité CDA |
|
||||||
|
| Styles | **Tailwind CSS** | 4.x | Utility-first, tokens design, `@media print` piloté par classes |
|
||||||
|
| Markdown | **marked** | 12.x+ | Rendu Markdown → HTML, léger et synchrone, configurable |
|
||||||
|
| Assainissement | **dompurify** | 3.x | Neutralise tout contenu HTML non sûr avant injection (anti-XSS) |
|
||||||
|
| Persistance | **localStorage** (natif) | — | Aucun backend, données 100 % locales |
|
||||||
|
| PWA | **@serwist/next** (fork maintenu de `next-pwa`) | 9.x/10.x | Register + precache des pages statiques, manifest, offline |
|
||||||
|
| Icônes / UX | **lucide-react** | ≥ 0.4 | Icônes légères, cohérentes, arborescentes |
|
||||||
|
| Gestion de paquets | **pnpm** | ≥ 9 | Hooks de workspace, rapidité, espace disque (cf. choix pnpm) |
|
||||||
|
|
||||||
|
> **Rendu Markdown côté client** : les zones interactives étant des Client Components, `marked` et `dompurify` sont importés uniquement dans la couche `lib/markdown.ts` consommée par les composants client. Le bundle initial des pages statiques ne les embarque volontairement pas.
|
||||||
|
|
||||||
|
## 2. Architecture en couches
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ app/ (Next.js) │
|
||||||
|
│ layout.tsx · globals.css │
|
||||||
|
│ page.tsx → CvWorkspace (page principale) │
|
||||||
|
│ documentation/page.tsx → page statique │
|
||||||
|
│ mentions-legales/page.tsx → page statique │
|
||||||
|
├────────────────────────────────────────────────────────────┤
|
||||||
|
│ components/ (React, réutilisables) │
|
||||||
|
│ ui/ → primitives génériques (Button, Tabs, ...) │
|
||||||
|
│ cv/ → composants métier du CV │
|
||||||
|
├────────────────────────────────────────────────────────────┤
|
||||||
|
│ lib/ (logique pure, testable) │
|
||||||
|
│ types/cv.ts → types & constantes du domaine │
|
||||||
|
│ markdown.ts → md → html sanitized │
|
||||||
|
│ storage.ts → API localStorage + migration │
|
||||||
|
│ hooks/ → useLocalStorage, useCVData │
|
||||||
|
├────────────────────────────────────────────────────────────┤
|
||||||
|
│ public/ → manifest.webmanifest, icônes, … │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Règles de couches
|
||||||
|
|
||||||
|
1. `components/ui/` n'importe **jamais** `lib/storage` ni les types CV (il est générique).
|
||||||
|
2. `components/cv/` orchestre la logique (via hooks) et compose les primitives `ui/`.
|
||||||
|
3. `lib/` ne dépend **jamais** des composants ; il est pur (aucun JSX).
|
||||||
|
4. La persistance (`useLocalStorage`) est isolée dans `lib/hooks/` pour être remplaçable (autre backend plus tard) sans toucher les composants.
|
||||||
|
5. Les pages `/documentation` et `/mentions-legales` sont des **Server Components** (statiques, aucune interactivité).
|
||||||
|
|
||||||
|
## 3. Répartition client / serveur
|
||||||
|
|
||||||
|
| Élément | Côté | Raison |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CvWorkspace`, panneaux, textareas, preview | **Client** (`"use client"`) | Interactivité temps réel, localStorage, `window` |
|
||||||
|
| `/documentation`, `/mentions-legales` | **Serveur** (SSG) | Contenu statique, zéro JS nécessaire |
|
||||||
|
| `layout.tsx`, metadata, manifest | **Serveur** | SEO/meta, PWA |
|
||||||
|
|
||||||
|
## 4. Flux de données (vue temps réel)
|
||||||
|
|
||||||
|
```
|
||||||
|
Frappe utilisateur
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CvInputPanel (textarea, onChange)
|
||||||
|
│ met à jour l'état global
|
||||||
|
▼
|
||||||
|
useCVData (useState + useLocalStorage 🔁)
|
||||||
|
│ ┌───────────────────┐
|
||||||
|
├──────── persist ▶ localStorage │ hydration au boot│
|
||||||
|
│ └───────────────────┘
|
||||||
|
▼
|
||||||
|
MarkdownTextarea / ExperienceManager mettent à jour `data`
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CvPreview (pure : props données)
|
||||||
|
│ rendu md → html via lib/markdown (marked + DOMPurify)
|
||||||
|
▼
|
||||||
|
Feuille A4 (sections positionnées, @media print)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Temps réel** : chaque frappe provoque une réécriture de l'état React → re-rendu du preview. Pas de debounce bloquant (les textes de CV sont courts) ; un `onChange` direct suffit.
|
||||||
|
- **Persistance** : synchronisée dans le même cycle (effet sur l'état) ; voir `03-modele-donnees.md`.
|
||||||
|
|
||||||
|
## 5. Détail des dossiers Next.js (App Router)
|
||||||
|
|
||||||
|
### 5.1 `app/`
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
| --- | --- |
|
||||||
|
| `app/layout.tsx` | Layout racine : `<html lang="fr">`, metadata (titre, description), icône, manifest PWA |
|
||||||
|
| `app/page.tsx` | Page principale : rend `CvWorkspace` |
|
||||||
|
| `app/documentation/page.tsx` | Contenu Markdown statique (aide d'utilisation) |
|
||||||
|
| `app/mentions-legales/page.tsx` | Mentions légales / vie privée |
|
||||||
|
| `components/ui/Footer.tsx` | Pied de page (lien Mentions légales, masqué à l'impression) |
|
||||||
|
| `app/globals.css` | Tokens Tailwind, styles de base, **styles d'impression @media print** |
|
||||||
|
|
||||||
|
### 5.2 `components/`
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
├── ui/
|
||||||
|
│ ├── Button.tsx → variantes (primary, ghost, danger, icon)
|
||||||
|
│ ├── Tabs.tsx → primitive d'onglets (accessible, contenu piloté)
|
||||||
|
│ ├── Textarea.tsx → textarea générique stylée
|
||||||
|
│ └── IconButton.tsx → bouton icône (Modifier, Supprimer)
|
||||||
|
└── cv/
|
||||||
|
├── CvWorkspace.tsx → layout 2 colonnes + barre d'app
|
||||||
|
├── CvToolbar.tsx → barre d'application (print, export/import JSON, liens)
|
||||||
|
├── CvInputPanel.tsx → panneau gauche
|
||||||
|
│ ├── CvTabs.tsx → tabs 1..5 (labels + numéros)
|
||||||
|
│ ├── MarkdownTextarea.tsx → {label, value, onChange} (textarea md)
|
||||||
|
│ └── ExperienceManager.tsx
|
||||||
|
│ └── ExperienceItem.tsx → ligne « n. titre [Modifier] [×] »
|
||||||
|
└── CvPreview.tsx → panneau droit (feuille A4)
|
||||||
|
├── CvHeader.tsx → accroche (zone 1)
|
||||||
|
├── CvLeftColumn.tsx → 1/3 (zones 2-5)
|
||||||
|
│ └── CvSection.tsx → section générique {titre, html}
|
||||||
|
└── CvRightColumn.tsx → 2/3 (Expériences professionnelles)
|
||||||
|
└── CvExperiencePreview.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Gestion d'état
|
||||||
|
|
||||||
|
- **État global minimal** : pas de Redux/Zustand nécessaire. Un seul état racine porté par `useCVData` (voir `03-`) et transmis par **props** (Composition). Les textareas sont **contrôlés** par cet état.
|
||||||
|
- État partagé entre `CvInputPanel` et `CvPreview` via **remontée d'état** (`CvWorkspace`).
|
||||||
|
- `ExperienceManager` gère son état local de navigation (expérience en cours d'édition, « mode ajout »).
|
||||||
|
|
||||||
|
## 7. Impression PDF
|
||||||
|
|
||||||
|
- Bouton « Imprimer / PDF » → `window.print()`.
|
||||||
|
- Feuille A4 visée via `@media print` dans `globals.css` :
|
||||||
|
- masquage du panneau gauche et de la barre d'outils (`.print:hidden`) ;
|
||||||
|
- la feuille A4 occupe la page entière : `@page { size: A4; margin: 0 }` ;
|
||||||
|
- `print-color-adjust: exact` pour conserver les couleurs d'accentuations éventuelles.
|
||||||
|
- Le PDF est produit par le navigateur (option « Enregistrer en PDF »).
|
||||||
|
|
||||||
|
## 8. PWA
|
||||||
|
|
||||||
|
- Package : `@serwist/next` (plugin Next.js officiel pour PWA).
|
||||||
|
- Config `next.config.ts` : `withSerwist({ swSrc, register: true })`.
|
||||||
|
- `sw.ts` : précache des routes `/`, `/documentation`, `/mentions-legales` + assets (icônes, manifest).
|
||||||
|
- `public/manifest.webmanifest` : `name`, `short_name`, `start_url: "/"`, `display: "standalone"`, `theme_color`, `icons` (192 et 512 px, PNG).
|
||||||
|
- Icônes générées dans `public/icons/` (192, 512, masque d'application 1024) — voir `05-`.
|
||||||
|
|
||||||
|
## 9. Scripts (package.json)
|
||||||
|
|
||||||
|
| Script | Commande |
|
||||||
|
| --- | --- |
|
||||||
|
| `dev` | `pnpm dev` — serveur de développement |
|
||||||
|
| `build` | `pnpm build` — build de production |
|
||||||
|
| `start` | `pnpm start` — démarrage prod local |
|
||||||
|
| `lint` | `pnpm lint` — ESLint (Next.js) |
|
||||||
|
| `typecheck` | `tsc --noEmit` — validation des types |
|
||||||
|
|
||||||
|
## 10. Choix techniques justifiés
|
||||||
|
|
||||||
|
- **pnpm** : rapide, économise l'espace disque (hardlinks), verrouillage strict des dépendances ; documenté de bout en bout dans `05-`.
|
||||||
|
- **TypeScript strict** : démontre la maîtrise du typage, évite les bugs d'état (union des zones, types des expériences).
|
||||||
|
- **marked + dompurify** : le binôme standard pour « Markdown sûr côté navigateur ». `marked` ne sanitiase pas nativement → DOMPurify est le garde-fou (voir `06-`).
|
||||||
|
- **tailwind `print:hidden` / `print:`** : contrôle CSS pur de l'impression PDF sans librairie tierce.
|
||||||
|
- **@serwist/next** : successeur maintenu de `next-pwa`, typé, pensé pour l'App Router.
|
||||||
|
|
||||||
|
## 11. Déploiement conteneurisé
|
||||||
|
|
||||||
|
### Pourquoi Docker ?
|
||||||
|
|
||||||
|
- **Reproductibilité** : l'image de production embarque la même version de Node, de pnpm, et les mêmes dépendances compilées.
|
||||||
|
- **Portabilité** : `docker run` fonctionne sur n'importe quel hôte doté du démon Docker (Linux, macOS, Windows, cloud).
|
||||||
|
- **Distribution via Docker Hub** : l'image est tagguée et poussée sur un registre public → n'importe qui peut la tirer et l'exécuter (`docker pull`).
|
||||||
|
|
||||||
|
### Architecture de l'image (multi-stage)
|
||||||
|
|
||||||
|
```
|
||||||
|
node:20-alpine
|
||||||
|
│
|
||||||
|
├─ deps → pnpm install --frozen-lockfile (cache, rapide)
|
||||||
|
├─ builder → pnpm build (sortie standalone Next.js)
|
||||||
|
└─ runner → node server.js (image finale ≈ 100-150 Mo)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Le champ `output: "standalone"` dans `next.config.ts` génère un `server.js` autonome dans `.next/standalone` → **aucun node_modules complet** embarqué dans l'image finale.
|
||||||
|
- Base `node:20-alpine` : image runtime minimale, compatible glibc/musl (ce projet n'utilise pas de dépendances natives).
|
||||||
|
|
||||||
|
### Fichiers associés
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
| --- | --- |
|
||||||
|
| `Dockerfile` | 3 stages (`deps` → `builder` → `runner`), `EXPOSE 3000`, `HOSTNAME=0.0.0.0` |
|
||||||
|
| `.dockerignore` | Exclut `node_modules/`, `.next/`, `docs/`, `*.md`, `.git/`, `.env*` |
|
||||||
|
| `docker-compose.yml` | Service `cv-maker`, build, ports `3000:3000`, healthcheck `wget` |
|
||||||
|
|
||||||
|
### Note sur `output: "standalone"`
|
||||||
|
|
||||||
|
Ajouter dans `next.config.ts` (côte à côte avec `withSerwist`) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export default withSerwist(nextConfig, { output: "standalone" });
|
||||||
|
```
|
||||||
|
|
||||||
|
ou bien :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const nextConfig = { output: "standalone" };
|
||||||
|
export default withSerwist(nextConfig);
|
||||||
|
```
|
||||||
|
|
||||||
|
L'ensemble `withSerwist` + `standalone` est compatible : le service worker (`sw.js`) est généré dans `public/` lors du build et copié dans l'image finale avec le reste du dossier `public/`.
|
||||||
|
|
||||||
|
### Variables d'environnement au runtime
|
||||||
|
|
||||||
|
| Variable | Valeur | Rôle |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `NODE_ENV` | `production` | Désactive les warnings React, active les optimisations Next.js |
|
||||||
|
| `HOSTNAME` | `0.0.0.0` | Écoute sur toutes les interfaces (sinon `localhost` uniquement → conteneur inaccessible) |
|
||||||
|
| `PORT` | `3000` | Port du serveur Next.js (mappé via `-p 3000:3000`) |
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# 03 — Modèle de données
|
||||||
|
|
||||||
|
## 1. Principes
|
||||||
|
|
||||||
|
- **Aucun serveur** : le « modèle de données » = structure de l'état applicatif + son image persistée dans **localStorage**.
|
||||||
|
- **Une seule clé de stockage** versionnée : `cv-maker:v1`.
|
||||||
|
- **Toutes les zones sont du Markdown brut** (chaînes de caractères). Le rendu HTML est calculé à la volée, jamais stocké.
|
||||||
|
- Les expériences sont une **liste d'objets** (avec identifiant stable), contrairement aux 5 zones simples.
|
||||||
|
|
||||||
|
## 2. Contracts TypeScript
|
||||||
|
|
||||||
|
`lib/types/cv.ts` :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Identifiant des 5 zones de saisie éditées par onglets. */
|
||||||
|
export type CVTabId =
|
||||||
|
| "accroche"
|
||||||
|
| "etatCivil"
|
||||||
|
| "formations"
|
||||||
|
| "softSkills"
|
||||||
|
| "centresInteret";
|
||||||
|
|
||||||
|
/** Ordre d'affichage des onglets (1 → 5). */
|
||||||
|
export const CV_TAB_ORDER: CVTabId[] = [
|
||||||
|
"accroche",
|
||||||
|
"etatCivil",
|
||||||
|
"formations",
|
||||||
|
"softSkills",
|
||||||
|
"centresInteret",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Métadonnées d'affichage d'un onglet. */
|
||||||
|
export interface CVTabMeta {
|
||||||
|
id: CVTabId;
|
||||||
|
numero: 1 | 2 | 3 | 4 | 5; // numéro affiché sur l'onglet
|
||||||
|
label: string; // libellé court de l'onglet
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CV_TABS: Record<CVTabId, CVTabMeta> = {
|
||||||
|
accroche: { id: "accroche", numero: 1, label: "Accroche" },
|
||||||
|
etatCivil: { id: "etatCivil", numero: 2, label: "État civil" },
|
||||||
|
formations: { id: "formations", numero: 3, label: "Formations" },
|
||||||
|
softSkills: { id: "softSkills", numero: 4, label: "Soft skills" },
|
||||||
|
centresInteret: { id: "centresInteret", numero: 5, label: "Centres d'intérêt" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Une expérience professionnelle. */
|
||||||
|
export interface Experience {
|
||||||
|
id: string; // crypto.randomUUID() — identifiant stable, jamais l'index
|
||||||
|
titre: string; // titre affiché dans la liste du panneau gauche + section CV
|
||||||
|
contenu: string; // corps de l'expérience en Markdown
|
||||||
|
createdAt: number; // timestamp (ms) de création
|
||||||
|
updatedAt: number; // timestamp (ms) de dernière modification
|
||||||
|
}
|
||||||
|
|
||||||
|
/** État complet du CV (et contenu de la clé localStorage). */
|
||||||
|
export interface CVCareerData {
|
||||||
|
version: 1; // version du schéma → déclenche les migrations
|
||||||
|
accroche: string; // zone 1
|
||||||
|
etatCivil: string; // zone 2
|
||||||
|
formations: string; // zone 3
|
||||||
|
softSkills: string; // zone 4
|
||||||
|
centresInteret: string; // zone 5
|
||||||
|
experiences: Experience[]; // liste, ordre = ordre d'affichage dans le CV
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Données restituées après hydratation locale. */
|
||||||
|
export type CVStorage = CVCareerData; // alias sémantique pour le contrat localStorage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Champs liés à l'onglet 2 (état civil + contacts + site + GitHub)
|
||||||
|
|
||||||
|
Le GOAL.md précise que la zone 2 contient « état civil + contacts + site web + github ». Ces informations restent **un seul bloc Markdown** (`etatCivil`) rendu dans la section « État civil » — pas de structuration fine en sous-champs : l'utilisateur formate librement (ex. `**Jean Dupont**` / `jean.dupont@mail.fr` / `github.com/jean`).
|
||||||
|
|
||||||
|
## 3. Données par défaut
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function createDefaultCVData(): CVCareerData {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
accroche: "",
|
||||||
|
etatCivil: "",
|
||||||
|
formations: "",
|
||||||
|
softSkills: "",
|
||||||
|
centresInteret: "",
|
||||||
|
experiences: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Couche de stockage `lib/storage.ts`
|
||||||
|
|
||||||
|
Rôles :
|
||||||
|
- lecture / écriture de la clé `cv-maker:v1` ;
|
||||||
|
- **parsing sécurisé** (try/catch, validation minimale de forme) ;
|
||||||
|
- point d'extension pour une future **migration de version** ;
|
||||||
|
- nom de clé centralisé (constante `STORAGE_KEY`).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const STORAGE_KEY = "cv-maker:v1";
|
||||||
|
|
||||||
|
export interface CVStorageAdapter {
|
||||||
|
load(): CVCareerData | null;
|
||||||
|
save(data: CVCareerData): void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalStorageAdapter(): CVStorageAdapter; // impl. try/catch
|
||||||
|
```
|
||||||
|
|
||||||
|
Détails d'implémentation attendus :
|
||||||
|
|
||||||
|
| Opération | Comportement |
|
||||||
|
| --- | --- |
|
||||||
|
| `load()` | `localStorage.getItem(STORAGE_KEY)` → `JSON.parse` → validation de forme (`version === 1`, `experiences` est un tableau) → merge avec les valeurs par défaut pour les champs manquants. Renvoie `null` si rien / invalide. |
|
||||||
|
| `save()` | `JSON.stringify(data)` → `setItem`. Erreur (quota dépassé, mode privé) capturée et journalisée (`console.warn`) par le hook, sans affichage bloquant. |
|
||||||
|
| `clear()` | `removeItem(STORAGE_KEY)` — utilisé par un éventuel bouton « Réinitialiser ». |
|
||||||
|
|
||||||
|
## 5. Hook `useLocalStorage` (`lib/hooks/useLocalStorage.ts`)
|
||||||
|
|
||||||
|
Hook générique de synchronisation état React ↔ localStorage :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useLocalStorage<T>(
|
||||||
|
storage: StorageLike<T>,
|
||||||
|
initialValue: T,
|
||||||
|
enable?: boolean,
|
||||||
|
): [value: T, setValue: (v: T | ((prev: T) => T)) => void];
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Hydratation sûre** : l'état démarre sur `initialValue` (identique au SSR) ; le `load()` est décalé dans un effet (`setTimeout 0`) après montage pour éviter toute divergence serveur/client.
|
||||||
|
- **Écriture automatique** : `useEffect` sur `value` → `save(value)` à chaque changement (délai 60 ms).
|
||||||
|
- **Sans indicateur de statut** : l'enregistrement est silencieux ; les erreurs de chargement/sauvegarde sont uniquement journalisées (`console.warn`).
|
||||||
|
- Peut être **désactivé** (3ᵉ paramètre `enable = false`) si nécessaire.
|
||||||
|
|
||||||
|
## 6. Hook métier `useCVData` (`lib/hooks/useCVData.ts`)
|
||||||
|
|
||||||
|
Regroupe toute la logique d'édition du CV, façade unique pour les composants :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface UseCVDataReturn {
|
||||||
|
data: CVCareerData;
|
||||||
|
|
||||||
|
setText(tab: CVTabId, value: string): void; // édition d'une zone 1..5
|
||||||
|
addExperience(titre: string, contenu: string): void; // ajoute en fin de liste
|
||||||
|
updateExperience(id: string, titre: string, contenu: string): void;
|
||||||
|
removeExperience(id: string): void; // suppression + confirmation côté UI
|
||||||
|
resetCV(): void; // restauration des valeurs par défaut
|
||||||
|
importCV(value: CVCareerData): void; // remplace les données (import JSON)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Règles d'implémentation :
|
||||||
|
|
||||||
|
| Méthode | Comportement |
|
||||||
|
| --- | --- |
|
||||||
|
| `setText(tab, value)` | immuable : crée une nouvelle copie `{ ...prev, [tab]: value }` ; met à jour les timestamps des expériences concernées si besoin (aucun pour les zones simples). |
|
||||||
|
| `addExperience(titre, contenu)` | crée `{ id: crypto.randomUUID(), titre, contenu, createdAt: Date.now(), updatedAt: same }` et **push** (fin de liste = le nouvel ajout suit le dernier saisi). |
|
||||||
|
| `updateExperience(id, …)` | remplace titre/contenu, réécrit `updatedAt`, conserve `id`, `createdAt` et la **position** dans la liste. |
|
||||||
|
| `removeExperience(id)` | filtre sur `id` (pas l'index). |
|
||||||
|
|
||||||
|
### Ordre de saisie (ordre d'affichage)
|
||||||
|
|
||||||
|
L'affichage respecte **l'ordre de saisie** : la 1ʳᵉ expérience ajoutée est affichée en haut (numérotée 1), la suivante en dessous, etc. Pas de tri par date (`createdAt`/`updatedAt` ne servent pas à ordonner l'affichage). Modifier ou supprimer une entrée ne change pas l'ordre des autres. La numérotation de la liste du panneau gauche (1, 2, 3…) reflète cet ordre et correspond au haut → bas de la section « Expériences professionnelles » du CV.
|
||||||
|
|
||||||
|
> Évolution prévue : ajouter `periodeDebut`/`periodeFin` + tri antichronologique ou drag & drop. Les champs `createdAt`/`updatedAt` sont déjà prévus pour supporter ce tri.
|
||||||
|
|
||||||
|
## 7. Sérialisation et intégrité
|
||||||
|
|
||||||
|
| Point | Règle |
|
||||||
|
| --- | --- |
|
||||||
|
| Taille | Contenus de CV : quelques Ko ; bien sous la limite localStorage (5–10 Mo selon navigateur). |
|
||||||
|
| Reload | L'hydratation se fait au `mount` (SSR désactivé pour la partie interactive) : pas de flash de rendu erroné. |
|
||||||
|
| Version | Si `version` de la sauvegarde ≠ `1` → migration `migrateData` (no-op pour v1) ou repli sur les valeurs par défaut. |
|
||||||
|
| Corruption | JSON invalide → ignore la sauvegarde et démarre avec `createDefaultCVData()`, sans crash. |
|
||||||
|
|
||||||
|
## 8. Anti-patterns à éviter
|
||||||
|
|
||||||
|
- ❌ Stoker le HTML rendu dans localStorage (obsolete dès que la version de `marked` change) → on stocke **toujours le Markdown source**.
|
||||||
|
- ❌ Utiliser l'index du tableau comme identifiant (cas d'une suppression → mauvaise référence dans React `key` et dans `updateExperience`).
|
||||||
|
- ❌ Écrire dans localStorage pendant le rendu initial (dépendances d'effet non maîtrisées) → écriture uniquement dans `useEffect` déclenché par un changement d'état.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# 04 — Spécifications UI et composants
|
||||||
|
|
||||||
|
## 1. Design tokens (Tailwind CSS 4)
|
||||||
|
|
||||||
|
Fichier source : `app/globals.css` (via `@import "tailwindcss"` + `@theme`).
|
||||||
|
|
||||||
|
| Token | Valeur | Usage |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--color-surface` | blanc (`#ffffff`) | feuille A4 |
|
||||||
|
| `--color-paper` | `#f1f5f9` gris très clair | fond de l'application hors feuille |
|
||||||
|
| `--color-ink` | `#0f172a` | texte principal |
|
||||||
|
| `--color-muted` | `#64748b` | texte secondaire |
|
||||||
|
| `--color-accent` | `#2563eb` | actions, focus, onglet actif |
|
||||||
|
| `--color-danger` | `#dc2626` | boutons « × » suppression |
|
||||||
|
| `--color-border` | `#e2e8f0` | bordures et séparateurs |
|
||||||
|
| `--radius-*` | `0.5rem` (md), `0.375rem` (sm) | cohérence des angles |
|
||||||
|
| `--font-sans` | système + `Inter` en fallback | lisibilité écran et impression |
|
||||||
|
|
||||||
|
Typographie du CV (feuille A4) :
|
||||||
|
|
||||||
|
| Élément | Suggestion |
|
||||||
|
| --- | --- |
|
||||||
|
| Nom / accroche (header) | `text-base font-semibold` à `text-xl font-bold` |
|
||||||
|
| Titres de section | `text-[0.75rem] uppercase tracking-wide text-muted font-bold` |
|
||||||
|
| Corps | `text-[0.8rem] leading-snug text-ink` |
|
||||||
|
|
||||||
|
### Palette neutre recommandée
|
||||||
|
|
||||||
|
Le CV imprimé doit rester sobre : **encre sombre** sur blanc, onglet actif en accent, **aucun fond de couleur sur la feuille A4** (économie d'encre et lisibilité).
|
||||||
|
|
||||||
|
## 2. Barre d'application (`CvToolbar`)
|
||||||
|
|
||||||
|
| Élément | Contenu |
|
||||||
|
| --- | --- |
|
||||||
|
| Gauche | Logo (icône `FileText`) + « CV-Maker » |
|
||||||
|
| Droite | Bouton **Imprimer / PDF**, bouton **Importer JSON**, bouton **Exporter JSON**, bouton **Réinitialiser**, lien **Documentation** |
|
||||||
|
|
||||||
|
Rendre les liens actifs en `font-semibold` et inactifs en `text-muted` (page courante) — navigation classique App Router (`usePathname`).
|
||||||
|
|
||||||
|
## 2bis. Pied de page (`Footer`)
|
||||||
|
|
||||||
|
- **Masqué à l'impression** (`print-hidden`), bordure supérieure.
|
||||||
|
- Contenu : nom de l'application + lien **Mentions légales** vers `/mentions-legales` (remplace l'ancien lien RGPD de la barre).
|
||||||
|
|
||||||
|
## 3. Panneau latéral gauche
|
||||||
|
|
||||||
|
### 3.1 `CvTabs` — onglets 1 à 5
|
||||||
|
|
||||||
|
- Basé sur la primitive `ui/Tabs` : `role="tablist"`, boutons `role="tab"`, `aria-selected`, `role="tabpanel"`.
|
||||||
|
- Affichage : barre de 5 boutons horizontaux, libellé = `numéro + label` (`1 · Accroche`, `2 · État civil`, …).
|
||||||
|
- Onglet actif : fond `accent`, texte blanc ; inactifs : bordure `border` + texte `muted`.
|
||||||
|
- **Contenu piloté par le parent** (`activeTab` dans `CvInputPanel`) : un seul `MarkdownTextarea` monté pour l'onglet actif. Le texte de l'onglet actif est le `value` contrôlé.
|
||||||
|
|
||||||
|
### 3.2 `MarkdownTextarea`
|
||||||
|
|
||||||
|
| Élément | Spécification |
|
||||||
|
| --- | --- |
|
||||||
|
| Props | `label: string`, `value: string`, `onChange(text: string): void`, `placeholder?: string` |
|
||||||
|
| Comportement | `textarea` contrôlé, `onChange` → mise à jour immédiate de l'état + persistance |
|
||||||
|
| UX | `min-h` variable (accroche + courte), `resize-y`, monospace en saisie (`font-mono`) avec bascule visuelle explicite « le texte est en Markdown » |
|
||||||
|
| Note Markdown | petite légende « Syntaxe Markdown supportée » avec lien vers `/documentation` |
|
||||||
|
|
||||||
|
### 3.3 `ExperienceManager`
|
||||||
|
|
||||||
|
Composition :
|
||||||
|
- **En-tête** : titre « Expériences » + bouton **+** (`ui/Button`, variante `primary`) → prépare une nouvelle expérience (zones titre/contenu vidées, focus du formulaire).
|
||||||
|
- **Formulaire** : deux champs — `titre` (input texte) + `contenu` (textarea Markdown, même composant que les zones 1-5). Boutons : **Enregistrer** et **Annuler** (annulation quand modification).
|
||||||
|
- **Liste numérotée** : sous le formulaire.
|
||||||
|
|
||||||
|
États internes suivants :
|
||||||
|
```ts
|
||||||
|
type ExperienceEditor =
|
||||||
|
| { mode: "closed" } // rien en édition
|
||||||
|
| { mode: "new" } // ajout en cours
|
||||||
|
| { mode: "edit"; id: Experience["id"] }; // modification en cours
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Ligne d'expérience (`ExperienceItem`)
|
||||||
|
|
||||||
|
Format visuel : `1. Titre de l'expérience [Modifier] [×]`
|
||||||
|
|
||||||
|
| Action | Clé | Comportement |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Modifier | bouton icône `Pencil` + libellé | passe en mode `edit`, recharge titre/contenu dans le formulaire |
|
||||||
|
| Supprimer | bouton `×` (`IconButton`, variante danger) | **confirmation** (petit état interne « Confirmer la suppression ? [Oui/Non] » ou `window.confirm`) puis `removeExperience(id)` |
|
||||||
|
|
||||||
|
> Composants `ui/Button`, `ui/IconButton`, `ui/Textarea`, `ui/Tabs` sont les primitives réutilisables (cf. le volet « composants réutilisables » du cahier des charges).
|
||||||
|
|
||||||
|
## 4. Panneau latéral droit — feuille A4 (`CvPreview`)
|
||||||
|
|
||||||
|
### 4.1 Conteneur de feuille
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="cv-sheet"> {/* wrapper lecture imprimée */}
|
||||||
|
<header>CvHeader</header>
|
||||||
|
<div className="cv-sheet-body">
|
||||||
|
<aside>CvLeftColumn</aside> {/* 1/3 */}
|
||||||
|
<main>CvRightColumn</main> {/* 2/3 */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Dimensions écran :
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
| --- | --- |
|
||||||
|
| Largeur | `210mm` (with `max-w-full` pour rester contenu) |
|
||||||
|
| Hauteur | `297mm` (`min-h`, la feuille peut être plus longue si contenu dépassant — dépassement scrollable) |
|
||||||
|
| Ombre / bordure | `shadow-lg border border-border` (écran) / **supprimées** à l'impression |
|
||||||
|
| Padding | `16mm` (marges type CV) |
|
||||||
|
| Fond | blanc `surface` |
|
||||||
|
|
||||||
|
Ratio 1/3–2/3 : `.cv-sheet-body { display:grid; grid-template-columns: 1fr 2fr; gap: 12mm; }`.
|
||||||
|
|
||||||
|
### 4.2 `CvHeader` (accroche — zone 1)
|
||||||
|
|
||||||
|
- Toute la largeur de la feuille.
|
||||||
|
- Rendu Markdown de `data.accroche` (contenu typiquement : nom + poste + accroche).
|
||||||
|
- `border-bottom` de séparation avec le corps de feuille.
|
||||||
|
|
||||||
|
### 4.3 `CvLeftColumn` (sections 2 à 5, 1/3)
|
||||||
|
|
||||||
|
Section générique `CvSection` :
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CvSection title="État civil" html={htmlEtatCivil} />
|
||||||
|
<CvSection title="Formations" html={htmlFormations} />
|
||||||
|
<CvSection title="Soft skills" html={htmlSoftSkills} />
|
||||||
|
<CvSection title="Centres d'intérêt" html={htmlCentresInteret} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Titre stylé selon le token « titres de section » (petit, majuscules, espacement).
|
||||||
|
- Contenu : `dangerouslySetInnerHTML={{ __html: sanitizedHtml }}` **uniquement** si le HTML a été produit par `lib/markdown` (marked + DOMPurify) — jamais de HTML saisi brut.
|
||||||
|
- Si une section est vide → masquée (pas de cadre vide) ; le GOAL.md ne demande pas d'emplacement fixe vide.
|
||||||
|
|
||||||
|
### 4.4 `CvRightColumn` (expériences, 2/3)
|
||||||
|
|
||||||
|
- Titre fixe : **« Expériences professionnelles »** (selon token titres de section).
|
||||||
|
- Liste des `data.experiences` **dans l'ordre du tableau** = ordre de saisie (la 1ʳᵉ entrée en haut, cf. module 03).
|
||||||
|
- Chaque `CvExperiencePreview` : **titre** en gras + rendu Markdown du `contenu`.
|
||||||
|
- Section vide → texte léger : instruction « Ajoutez une expérience dans le panneau gauche » (rendu écran uniquement, masqué à l'impression via `print:hidden`).
|
||||||
|
|
||||||
|
### 4.5 Dépassement de contenu
|
||||||
|
|
||||||
|
- Sur le feuille : la hauteur est le **hapax A4** à l'impression (une page). En revanche, le contenu peut dépasser → `overflow` tolérable sur une « page 2 » de preview est hors scope v1. Comportement choisi : la feuille s'allonge (`min-h` seulement) pour montrer tout le contenu ; **l'impression** sera paginée par le navigateur (2 pages éventuelles). Documenté comme limitation assumée.
|
||||||
|
|
||||||
|
## 5. États UI transverses
|
||||||
|
|
||||||
|
| État | Signal |
|
||||||
|
| --- | --- |
|
||||||
|
| Erreur de sauvegarde | `console.warn` de l'erreur (enregistrement silencieux, sans pastille) |
|
||||||
|
| Onglet actif | fond accent (haut contraste) |
|
||||||
|
| Formulaire expérience (nouveau / modification) | zone surlignée `border-accent` |
|
||||||
|
| Suppression | état de confirmation inline (Oui / Non) |
|
||||||
|
|
||||||
|
## 6. Responsive minimal (nice to have)
|
||||||
|
|
||||||
|
- **≥ 1024 px** : mise en page 2 colonnes (1/3 – 2/3) inchangée.
|
||||||
|
- **< 1024 px** : le panneau gauche passe en `w-full` au-dessus de la preview (empilés verticalement) ; la preview se centre. Tablette viable ; mobile lisible mais non optimisé (desktop first).
|
||||||
|
- L'impression ne dépend **pas** du viewport (elle est pilotée par `@media print`, voir module 02 §7).
|
||||||
|
|
||||||
|
## 7. Accessibilité (bonnes pratiques)
|
||||||
|
|
||||||
|
- Onglets : `role="tablist"/"tab"/"tabpanel"`, navigation clavier (flèches ←/→), `aria-selected`, `aria-controls`, `id` liés.
|
||||||
|
- Boutons icônes : `aria-label` explicites (« Modifier l'expérience », « Supprimer l'expérience »).
|
||||||
|
- Inputs/textarea : `<label htmlFor>` avec `id` unique.
|
||||||
|
- Lien focus : contour `focus-visible` accent (Tailwind `focus-visible:outline-2` + `focus-visible:outline-accent`).
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
# 05 — Plan de réalisation pas-à-pas
|
||||||
|
|
||||||
|
> Feuille de route d'implémentation. Chaque étape produit un état du projet **vérifiable** (commande de lancement + contrôle visuel/fonctionnel). Les commandes utilisent **pnpm**.
|
||||||
|
|
||||||
|
## Étape 0 — Prérequis et vérification environnement
|
||||||
|
|
||||||
|
1. Vérifier Node : `node -v` (≥ 20 attendu).
|
||||||
|
2. Activer pnpm :
|
||||||
|
```bash
|
||||||
|
corepack enable
|
||||||
|
pnpm -v
|
||||||
|
```
|
||||||
|
(ou installer globalement avec `npm install -g pnpm` si corepack indisponible.)
|
||||||
|
3. Créer le répertoire de projet s'il n'existe pas.
|
||||||
|
|
||||||
|
## Étape 1 — Scaffold du projet Next.js (App Router + TypeScript)
|
||||||
|
|
||||||
|
Depuis la racine `cv-maker/` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm create next-app@latest . \
|
||||||
|
--typescript \
|
||||||
|
--tailwind \
|
||||||
|
--eslint \
|
||||||
|
--app \
|
||||||
|
--src-dir=false \
|
||||||
|
--import-alias "@/*" \
|
||||||
|
--turbopack \
|
||||||
|
--use-pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôles :
|
||||||
|
- `pnpm dev` puis ouvrir `http://localhost:3000` (page d'accueil Next.js) ;
|
||||||
|
- fichiers générés : `app/`, `components/` (facultatif), `lib/` (absent par défaut), `public/`, `next.config.ts`, `tsconfig.json`.
|
||||||
|
|
||||||
|
> Si le dossier contient déjà des fichiers, le scaffolder demande. Ici il est vide (seul `GOAL.md` présent → déplacer `GOAL.md` temporairement hors du dossier racine si le scaffolder refuse, puis le remettre).
|
||||||
|
|
||||||
|
## Étape 2 — Nettoyage du scaffold
|
||||||
|
|
||||||
|
1. Vider `app/page.tsx` (remplacer par un composant squelette `CvWorkspace` minimal).
|
||||||
|
2. Supprimer les assets de démo de `public/` (`vercel.svg`, `next.svg`, etc.) sauf ce qui est utile.
|
||||||
|
3. Nettoyer `app/globals.css` du style de démo : ne conserver que `@import "tailwindcss";` et les tokens/print CSS ajoutés plus tard.
|
||||||
|
4. Vérifier `pnpm dev` → page vide sans erreur.
|
||||||
|
|
||||||
|
## Étape 3 — Configuration TypeScript strict
|
||||||
|
|
||||||
|
Dans `tsconfig.json` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter un script de vérification dans `package.json` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôle : `pnpm typecheck` → aucune erreur.
|
||||||
|
|
||||||
|
## Étape 4 — Installation des dépendances
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add marked dompurify lucide-react
|
||||||
|
pnpm add @serwist/next # PWA (plugin + runtime)
|
||||||
|
```
|
||||||
|
|
||||||
|
DevDependencies :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add -D @types/dompurify
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôle : `pnpm typecheck` et `pnpm lint` restent verts après édition du package.json si besoin.
|
||||||
|
|
||||||
|
## Étape 5 — Types du domaine
|
||||||
|
|
||||||
|
Créer `lib/types/cv.ts` avec le contenu du module `03-` (contrats `CVTabId`, `CV_TAB_ORDER`, `CV_TABS`, `Experience`, `CVCareerData`, `createDefaultCVData`).
|
||||||
|
|
||||||
|
Contrôle : `pnpm typecheck` sans erreur.
|
||||||
|
|
||||||
|
## Étape 6 — Couche de stockage
|
||||||
|
|
||||||
|
Créer `lib/storage.ts` :
|
||||||
|
- constante `STORAGE_KEY = "cv-maker:v1"` ;
|
||||||
|
- interface `CVStorageAdapter` (`load`, `save`, `clear`) ;
|
||||||
|
- implémentation `createLocalStorageAdapter` avec try/catch (JSON invalide → `null`, quota → levée d'erreur remontée).
|
||||||
|
|
||||||
|
Créer `lib/hooks/useLocalStorage.ts` : hook générique (`value`, `setValue`, `status`) — écriture dans `useEffect`, initialisation paresseuse et garde `typeof window`.
|
||||||
|
|
||||||
|
Créer `lib/hooks/useCVData.ts` : façade `setText`, `addExperience`, `updateExperience`, `removeExperience`, `resetCV` (comportements du module `03-` §6).
|
||||||
|
|
||||||
|
Contrôle : `pnpm typecheck` vert.
|
||||||
|
|
||||||
|
## Étape 7 — Rendu Markdown sécurisé
|
||||||
|
|
||||||
|
Créer `lib/markdown.ts` :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { marked } from "marked";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
marked.setOptions({ gfm: true, breaks: true });
|
||||||
|
|
||||||
|
export function markdownToHtml(source: string): string {
|
||||||
|
const rawHtml = marked.parse(source, { async: false }) as string;
|
||||||
|
return DOMPurify.sanitize(rawHtml);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **DOMPurify côté client uniquement** : `dompurify` dépend de `window`. Ne l'utiliser que dans des Client Components (bilan : jamais importé dans `app/layout.tsx` ou pages statiques).
|
||||||
|
|
||||||
|
Contrôle : `pnpm typecheck` vert ; petit test manuel dans un `useEffect` de la page principale.
|
||||||
|
|
||||||
|
## Étape 8 — Primitives UI réutilisables
|
||||||
|
|
||||||
|
Créer sous `components/ui/` :
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
| --- | --- |
|
||||||
|
| `Button.tsx` | variantes `primary` / `ghost` / `danger` / `icon`, support `variant` + `size` |
|
||||||
|
| `IconButton.tsx` | bouton icône avec `aria-label` obligatoire en prop |
|
||||||
|
| `Textarea.tsx` | textarea contrôlé stylé (mono, `resize-y`) |
|
||||||
|
| `Tabs.tsx` | primitive d'onglets accessible (gère `role`/`aria-selected`, callbacks) |
|
||||||
|
|
||||||
|
Contrôle : intégration temporaire dans la page, `pnpm dev` + `pnpm typecheck`.
|
||||||
|
|
||||||
|
## Étape 9 — Composants du panneau gauche
|
||||||
|
|
||||||
|
Créer `components/cv/` dans cet ordre :
|
||||||
|
|
||||||
|
1. `CvInputPanel.tsx` — colonne gauche : gère `activeTab` (useState local), titre « Contenu du CV », compose `CvTabs` + `MarkdownTextarea` + `ExperienceManager`. Reçoit `data`, les callbacks de `useCVData` en props.
|
||||||
|
2. `CvTabs.tsx` — rend via `CV_TABS`/`CV_TAB_ORDER` (module 03) : boutons `numero + label`, état actif, aria.
|
||||||
|
3. `MarkdownTextarea.tsx` — `label` + `Textarea` contrôlé + lien « Aide Markdown ».
|
||||||
|
4. `ExperienceManager.tsx` — formulaire (titre + contenu), bouton +, liste `ExperienceItem`, gestion asynchrone des modes (`closed/new/edit`).
|
||||||
|
5. `ExperienceItem.tsx` — ligne `n. titre`, boutons Modifier / Supprimer, confirmation inline.
|
||||||
|
|
||||||
|
Contrôle visuel : saisir du texte dans chaque onglet, ajouter/modifier/supprimer des expériences (état React OK avant même la preview).
|
||||||
|
|
||||||
|
## Étape 10 — Composants de la preview A4
|
||||||
|
|
||||||
|
Créer dans `components/cv/` :
|
||||||
|
|
||||||
|
1. `CvPreview.tsx` — wrapper feuille (dimensions `210mm`/`297mm`, ombre, classe d'impression) ; appelle `markdownToHtml` depuis `lib/markdown` pour les 5 zones + chaque expérience ; compose header/colonnes.
|
||||||
|
2. `CvHeader.tsx` — accroche (zone 1).
|
||||||
|
3. `CvLeftColumn.tsx` — 4 × `CvSection` (État civil, Formations, Soft skills, Centres d'intérêt).
|
||||||
|
4. `CvSection.tsx` — titre stylé + `dangerouslySetInnerHTML` (HTML assaini) ; masquée si vide.
|
||||||
|
5. `CvRightColumn.tsx` — titre « Expériences professionnelles » + liste `CvExperiencePreview` + message vide alternatif (`print:hidden`).
|
||||||
|
6. `CvExperiencePreview.tsx` — titre en gras + HTML assaini du contenu.
|
||||||
|
|
||||||
|
Contrôle : remplir le panneau gauche → vérifier le rendu temps réel dans la feuille (structure 1/3–2/3, header).
|
||||||
|
|
||||||
|
## Étape 11 — Assemblage de la page principale
|
||||||
|
|
||||||
|
Créer `components/cv/CvWorkspace.tsx` :
|
||||||
|
- `"use client"` ;
|
||||||
|
- appelle `useCVData()` ;
|
||||||
|
- « remonte l'état » : passe `data` + callbacks à `CvInputPanel` et `data` à `CvPreview` ;
|
||||||
|
- compose `CvToolbar` au-dessus, grille `grid-cols-[1fr_2fr]` (desktop), stacking < 1024 px.
|
||||||
|
|
||||||
|
Créer `components/cv/CvToolbar.tsx` : logo + bouton « Imprimer / PDF » (`window.print()`), boutons « Importer JSON » puis « Exporter JSON » (import via fichier validé, téléchargement via `Blob`), bouton « Réinitialiser », lien `/documentation`. Créer `components/ui/Footer.tsx` (lié dans le layout, `print-hidden`) : nom de l'app + lien **Mentions légales**.
|
||||||
|
|
||||||
|
Remplacer le contenu de `app/page.tsx` :
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CvWorkspace } from "@/components/cv/CvWorkspace";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return <CvWorkspace />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôle : `pnpm dev` → éditeur temps réel fonctionnel, persistance au rechargement.
|
||||||
|
|
||||||
|
## Étape 12 — Impression PDF
|
||||||
|
|
||||||
|
Dans `app/globals.css`, ajouter :
|
||||||
|
|
||||||
|
```css
|
||||||
|
@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;
|
||||||
|
/* hauteur max nécessaire si le contenu dépasse une page */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Classes utilitaires : panneau gauche et toolbar marqués `print-hidden` (ou `print:hidden` de Tailwind via `print:` variant si configuré).
|
||||||
|
|
||||||
|
Contrôle : bouton « Imprimer / PDF » → aperçu ne contenant que la feuille A4 ; « Enregistrer en PDF » → fichier propre.
|
||||||
|
|
||||||
|
## Étape 13 — Pages Documentation et Mentions légales
|
||||||
|
|
||||||
|
- `app/documentation/page.tsx` : **Server Component** statique. Contenu : prise en main (tabs, expériences, impression, sauvegarde locale) + aide sur la syntaxe Markdown supportée (titres, gras, italique, listes, liens, code) illustrée d'exemples.
|
||||||
|
- `app/mentions-legales/page.tsx` : **Server Component** statique. Contenu conforme au module `06-`, sans paragraphe contact.
|
||||||
|
|
||||||
|
Ajouter un `Link` vers `/documentation` (via `CvToolbar`) et vers `/mentions-legales` (via le `Footer`), plus un en-tête de retour « ← Retour à l'éditeur » sur chaque page.
|
||||||
|
|
||||||
|
Contrôle : navigation depuis la toolbar (Documentation) et le footer (Mentions légales), `pnpm build` → routes générées statiques (`○` dans la sortie de build).
|
||||||
|
|
||||||
|
## Étape 14 — PWA (manifest + service worker)
|
||||||
|
|
||||||
|
Avec `@serwist/next` :
|
||||||
|
|
||||||
|
1. `next.config.ts` (inclut `output: "standalone"` requis par le Dockerfile) :
|
||||||
|
```ts
|
||||||
|
import withSerwistInit from "@serwist/next";
|
||||||
|
const nextConfig = { output: "standalone" };
|
||||||
|
const withSerwist = withSerwistInit({
|
||||||
|
swSrc: "app/sw.ts",
|
||||||
|
swDest: "public/sw.js",
|
||||||
|
disable: process.env.NODE_ENV === "development",
|
||||||
|
});
|
||||||
|
export default withSerwist(nextConfig);
|
||||||
|
```
|
||||||
|
2. Créer `app/sw.ts` : `import { defaultCache } from "@serwist/next/worker";` + `precacheAndRoute`/`registerRoute` sur `/`, `/documentation`, `/mentions-legales`, icônes, manifest.
|
||||||
|
3. Créer `public/manifest.webmanifest` (name, short_name, start_url `/`, display `standalone`, theme_color, icons 192/512).
|
||||||
|
4. Créer les icônes PNG dans `public/icons/` (192, 512) — génération locale (outil image, ou conversion SVG d'une icône proche du logo).
|
||||||
|
5. `app/layout.tsx` : ajouter `<link rel="manifest" href="/manifest.webmanifest">`, theme-color, description, icône apple-touch si souhaité.
|
||||||
|
|
||||||
|
Contrôle : `pnpm build` sans erreur ; en production (`pnpm start`), onglet Lighthouse/PWA → service worker actif, installable.
|
||||||
|
|
||||||
|
## Étape 15 — Réglages finaux / polish
|
||||||
|
|
||||||
|
- Espacements de la grille (gap), tailles de police du CV, comportement « feuille qui s'allonge »,
|
||||||
|
- Sauvegarde locale silencieuse (pastille de statut non retenue — inutile vu l'enregistrement automatique),
|
||||||
|
- États de focus, `aria-label` manquants,
|
||||||
|
- Ajout du bouton « Réinitialiser le CV » (optionnel, appelle `resetCV` après confirmation),
|
||||||
|
- Vérification des limites : contenu vide, expérience sans titre (titre par défaut « Expérience »),
|
||||||
|
- Mise à jour de `README.md` (documentation technique) et du dossier `docs/conception`.
|
||||||
|
|
||||||
|
## Étape 16 — Recette finale selon le GOAL.md
|
||||||
|
|
||||||
|
Exécuter la checklist complète du module `07-plan-tests.md`. Corriger toute anomalie, re-exécuter `pnpm lint` et `pnpm typecheck` (zéro erreur).
|
||||||
|
|
||||||
|
## Étape 17 — Dockerisation et publication sur Docker Hub
|
||||||
|
|
||||||
|
> Spécification complète : [`08-deploiement-docker.md`](./08-deploiement-docker.md)
|
||||||
|
|
||||||
|
**Prérequis** : Docker ou Docker Desktop installé, compte Docker Hub créé (`docker login` une seule fois).
|
||||||
|
|
||||||
|
### 17.1 Créer `.dockerignore`
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
.DS_Store
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôle : le contexte de build (`docker build .`) ne contient plus que les fichiers de source (app/, components/, lib/, public/, next.config.ts, tsconfig.json, package.json, pnpm-lock.yaml).
|
||||||
|
|
||||||
|
### 17.2 Créer `Dockerfile`
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# ── Étape de dépendances ────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.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 . .
|
||||||
|
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
|
||||||
|
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôle : aucune erreur au build.
|
||||||
|
|
||||||
|
### 17.3 Créer `docker-compose.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
cv-maker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: cv-maker:local
|
||||||
|
container_name: cv-maker
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
- PORT=3000
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôle : `docker compose up -d` → conteneur healthy, application accessible sur `http://localhost:3000`.
|
||||||
|
|
||||||
|
### 17.4 Build de l'image et test local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t cv-maker:latest .
|
||||||
|
docker run --rm -p 3000:3000 cv-maker:latest
|
||||||
|
# → ouvrir http://localhost:3000 et tester l'éditeur
|
||||||
|
```
|
||||||
|
|
||||||
|
Vérifications spécifiques conteneur :
|
||||||
|
- Le service worker (`sw.js`) est chargé (PWA fonctionne).
|
||||||
|
- La sauvegarde locale est fonctionnelle (localStorage côté navigateur).
|
||||||
|
- `docker images cv-maker` : taille < 200 Mo.
|
||||||
|
|
||||||
|
### 17.5 Publication sur Docker Hub
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Se connecter au registre
|
||||||
|
docker login
|
||||||
|
|
||||||
|
# 2. Taguer (remplacer <mon-user-dockerhub> par votre identifiant)
|
||||||
|
docker tag cv-maker:latest <mon-user-dockerhub>/cv-maker:latest
|
||||||
|
docker tag cv-maker:latest <mon-user-dockerhub>/cv-maker:1.0.0
|
||||||
|
|
||||||
|
# 3. Pousser
|
||||||
|
docker push <mon-user-dockerhub>/cv-maker:latest
|
||||||
|
docker push <mon-user-dockerhub>/cv-maker:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôle : l'image apparaît sur la page Docker Hub du compte (`https://hub.docker.com/r/<mon-user-dockerhub>/cv-maker`).
|
||||||
|
|
||||||
|
### 17.6 Déploiement depuis Docker Hub
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sur une autre machine
|
||||||
|
docker pull <mon-user-dockerhub>/cv-maker:latest
|
||||||
|
docker run -d -p 3000:3000 --name cv-maker <mon-user-dockerhub>/cv-maker:latest
|
||||||
|
# ou via compose (échanger image: dans docker-compose.yml)
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Récapitulatif des fichiers à créer/modifier
|
||||||
|
|
||||||
|
```
|
||||||
|
créés app/page.tsx (réécrit)
|
||||||
|
app/documentation/page.tsx
|
||||||
|
app/mentions-legales/page.tsx
|
||||||
|
app/sw.ts
|
||||||
|
app/layout.tsx (modifié : manifest, meta)
|
||||||
|
app/globals.css (modifié : tokens + @media print)
|
||||||
|
lib/types/cv.ts
|
||||||
|
lib/markdown.ts
|
||||||
|
lib/storage.ts
|
||||||
|
lib/hooks/useLocalStorage.ts
|
||||||
|
lib/hooks/useCVData.ts
|
||||||
|
components/ui/{Button,IconButton,Textarea,Tabs}.tsx
|
||||||
|
components/cv/{CvWorkspace,CvToolbar,CvInputPanel,CvTabs,
|
||||||
|
MarkdownTextarea,ExperienceManager,ExperienceItem,
|
||||||
|
CvPreview,CvHeader,CvLeftColumn,CvSection,
|
||||||
|
CvRightColumn,CvExperiencePreview}.tsx
|
||||||
|
public/manifest.webmanifest
|
||||||
|
public/icons/icon-192.png, public/icons/icon-512.png
|
||||||
|
Dockerfile (multi-stage : deps/builder/runner)
|
||||||
|
docker-compose.yml (service cv-maker, healthcheck)
|
||||||
|
.dockerignore
|
||||||
|
next.config.ts (modifié : withSerwist + output: "standalone")
|
||||||
|
tsconfig.json (modifié : strict)
|
||||||
|
package.json (modifié : scripts + deps)
|
||||||
|
modifié README.md (documentation technique)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Jalons de contrôle qualité
|
||||||
|
|
||||||
|
| Jalon | État | Contrôle |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| J1 (fin étape 4) | Deps installées | `pnpm typecheck`, `pnpm lint` |
|
||||||
|
| J2 (fin étape 11) | MVP fonctionnel | édition temps réel + persistance |
|
||||||
|
| J3 (fin étape 12) | PDF ok | impression → PDF propre |
|
||||||
|
| J4 (fin étape 13) | Pages statiques | routes OK, build ○ |
|
||||||
|
| J5 (fin étape 14) | PWA | installable, offline |
|
||||||
|
| J6 (fin étape 16) | Recette GOAL.md | 100 % critères passés |
|
||||||
|
| J7 (fin étape 17) | Docker + DockerHub | image < 200 Mo, `docker compose up`, image sur Docker Hub |
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# 06 — Sécurité et conformité RGPD
|
||||||
|
|
||||||
|
## 1. Analyse de risque (résumé)
|
||||||
|
|
||||||
|
| Menace | Vecteur | Niveau (v1) | Contre-mesure |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| XSS — injection de HTML/JS saisi par l'utilisateur | champ Markdown rendu via `dangerouslySetInnerHTML` | **élevé** si non traité | **DOMPurify.sanitize()** systématique sur le HTML produit par `marked` |
|
||||||
|
| Vol de données | exfiltration réseau | nul | **aucune donnée quitte le navigateur** (pas d'API, pas d'appel distant) |
|
||||||
|
| Corruption de la sauvegarde | JSON invalide en localStorage | faible | parsing try/catch + validation de forme + valeur par défaut |
|
||||||
|
| Quota localStorage dépassé | très gros CV | faible | erreur catchée + `console.warn` (enregistrement silencieux) |
|
||||||
|
| Données lues par un tiers sur le poste | accès local au navigateur | hors périmètre | relatives au poste utilisateur (aucune garantie applicative possible) |
|
||||||
|
|
||||||
|
Rappel : **le seul point d'entrée présentant un risque d'exécution** est le rendu HTML. Mitigé par la règle « aucun HTML brut n'est injecté, uniquement le résultat de `markdownToHtml` (marked → DOMPurify) ».
|
||||||
|
|
||||||
|
## 2. Chaîne de rendu sécurisée (obligatoire)
|
||||||
|
|
||||||
|
```
|
||||||
|
Markdown brut (textarea)
|
||||||
|
│ marked.parse (GFM, breaks)
|
||||||
|
▼
|
||||||
|
HTML intermédiaire non sûr
|
||||||
|
│ DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
|
||||||
|
▼
|
||||||
|
HTML assaini
|
||||||
|
│ dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
||||||
|
▼
|
||||||
|
DOM
|
||||||
|
```
|
||||||
|
|
||||||
|
### Règles d'implémentation
|
||||||
|
|
||||||
|
1. Toute injection dans le DOM passe **exclusivement** par `lib/markdown.ts` → `markdownToHtml`.
|
||||||
|
2. Ne jamais injecter la saisie brute (une `textarea` n'est pas un `<script>` : le texte est du texte ; le risque n'apparaît que lors du rendu HTML).
|
||||||
|
3. Ne pas surcharger la config DOMPurify par zone ; une configuration unique module `06-` est utilisée partout.
|
||||||
|
4. `marked` options stables : `gfm: true`, `breaks: true`. Ne pas activer d'extension/plugin non maîtrisé.
|
||||||
|
5. Le rendu est **déterministe** (pas de rendu serveur du Markdown utilisateur pour les zones interactives — le client est le seul excécutant).
|
||||||
|
6. `dompurify` est importé seulement dans les composants client (prérequis `window`), jamais dans un composant serveur/réseau.
|
||||||
|
|
||||||
|
### Ce que DOMPurify bloque (défauts)
|
||||||
|
|
||||||
|
- balises `<script>`, `<iframe>`, `<object>`, `<embed>`, `<svg>` non sûres ;
|
||||||
|
- gestionnaires d'événements inline (`onclick=`, `onerror=`) ;
|
||||||
|
- URLs `javascript:` dans `href`/`src` ;
|
||||||
|
- tout futur composant exotique inconnu.
|
||||||
|
|
||||||
|
## 3. Données personnelles : état de l'analyse
|
||||||
|
|
||||||
|
Les zones 1 à 5 du CV contiennent des **données à caractère personnel** (nom, e-mail, téléphone, histoire professionnelle).
|
||||||
|
|
||||||
|
| Propriété | État |
|
||||||
|
| --- | --- |
|
||||||
|
| Stockage | **localStorage du navigateur, sur le poste seulement** |
|
||||||
|
| Transmission | **aucune** (pas d'envoi réseau, pas de cookies, pas de tracker, pas d'analytics) |
|
||||||
|
| Durée de conservation | jusqu'à effacement des données du navigateur, ou « Réinitialiser le CV » dans l'application |
|
||||||
|
| Accès | celui qui possède le poste/navigateur |
|
||||||
|
| Base légale (contrôle qualité RGPD) | l'utilisateur est le seul responsable et destinataire ; aucune RGDP externe ne traite ces données |
|
||||||
|
|
||||||
|
**Conclusion** : l'application n'effectue **aucun traitement** débordant l'usage personnel de l'utilisateur. La page `/mentions-legales` doit le formuler clairement et fournir les droits usuels (accès, rectification, suppression — effectuables directement dans l'app/browser).
|
||||||
|
|
||||||
|
## 4. Contenu de la page `/mentions-legales` (modèle de rédaction)
|
||||||
|
|
||||||
|
Sections à inclure (v1 : **sans paragraphe contact**) :
|
||||||
|
|
||||||
|
1. **Titre** — « Mentions légales ».
|
||||||
|
2. **Éditeur / responsable de traitement** : l'utilisateur lui-même (pas d'entité tierce).
|
||||||
|
3. **Données traitées** : les contenus saisis (état civil, contacts, parcours…).
|
||||||
|
4. **Finalité** : production d'un CV local ; aucune transmission.
|
||||||
|
5. **Stockage** : localStorage du navigateur (clé `cv-maker:v1`), jamais envoyé à un serveur.
|
||||||
|
6. **Durée de conservation** : tant que les données du navigateur existent ; possibilité de suppression immédiate.
|
||||||
|
7. **Droits de la personne concernée** : libre disposition des données — modification/suppression dans l'éditeur ; effacement possible via les outils du navigateur (suggestion d'actions concrètes) ou le bouton « Réinitialiser ».
|
||||||
|
8. **Sécurité** : rendu assaini (aucune exécution de contenu saisi), aucune dépendance réseau.
|
||||||
|
9. **Contact** : *section retirée en v1* (cf. demande utilisateur).
|
||||||
|
10. **Date de mise à jour** de la page.
|
||||||
|
|
||||||
|
## 5. Sécurité applicative transversale
|
||||||
|
|
||||||
|
- **PWA/service worker** : le SW ne précache que des routes statiques internes ; aucun champ utilisateur n'y est inscrit (les données restent dans localStorage, hors périmètre du cache de pages).
|
||||||
|
- **Dépendances** : audit régulier `pnpm audit` ; mise à jour de `marked`/`dompurify` (les deux librairies de sécurité critiques).
|
||||||
|
- **Headers de réponse** (side) : valeurs par défaut convenables pour Next.js de production ; pas d'en-tête supplémentaire requis côté local (pas de cookies).
|
||||||
|
- **Content-Security-Policy** (recommandation) : à documenter comme évolution — les pages statiques ne chargent aucun script tiers, une CSP stricte est triviale à ajouter hors v1 si hébergé.
|
||||||
|
|
||||||
|
## 6. Checklist sécurité avant livraison
|
||||||
|
|
||||||
|
- [ ] Toute zone rendue passe par `markdownToHtml` (relire chaque `dangerouslySetInnerHTML`).
|
||||||
|
- [ ] Aucun appel réseau dans le code applicatif (grep `fetch(`, `axios`, `XMLHttpRequest` dans `app/` et `components/`).
|
||||||
|
- [ ] `pnpm audit` sans vulnérabilité de haut niveau.
|
||||||
|
- [ ] Test manuel d'injection : saisir `<img src=x onerror=alert(1)>` et `<script>alert(1)</script>` dans chaque zone → aucune exécution.
|
||||||
|
- [ ] Page `/mentions-legales` présente et linker depuis le footer (masqué à l'impression).
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# 07 — Plan de tests (recette manuelle)
|
||||||
|
|
||||||
|
> Périmètre v1 : **aucun test automatisé** (décision du GOAL.md : « pas de test pour démarrer — à prévoir pour plus tard »). La recette ci-dessous est un **plan de tests manuels** aligné sur les critères d'acceptation du GOAL.md. Elle servira aussi de **cahier de recette** pour le livrable ECF.
|
||||||
|
|
||||||
|
## 1. Environnement de test
|
||||||
|
|
||||||
|
| Paramètre | Valeur |
|
||||||
|
| --- | --- |
|
||||||
|
| Navigateur principal | Chrome / Edge (dernière version) — engine Chromium recommandé |
|
||||||
|
| Navigateur secondaire | Firefox (contrôle croisé) |
|
||||||
|
| Résolution principale | ≥ 1024 px (desktop) |
|
||||||
|
| Commande de lancement | `pnpm dev` (ou `pnpm build && pnpm start` pour la recette finale) |
|
||||||
|
|
||||||
|
## 2. Critères d'acceptation globaux (rappel GOAL.md)
|
||||||
|
|
||||||
|
- [ ] Application « desktop first » opérationnelle.
|
||||||
|
- [ ] Interface en deux colonnes respectant le ratio 1/3 – 2/3.
|
||||||
|
- [ ] Saisie Markdown à gauche, preview temps réel à droite au **format A4**.
|
||||||
|
- [ ] Sauvegarde localStorage à chaque frappe + restauration au rechargement.
|
||||||
|
- [ ] Impression du CV en PDF.
|
||||||
|
- [ ] Pages Documentation et Mentions légales accessibles.
|
||||||
|
|
||||||
|
## 3. Tests fonctionnels détaillés
|
||||||
|
|
||||||
|
### 3.1 Écran général et layout
|
||||||
|
|
||||||
|
| # | Action | Résultat attendu |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| G1 | Ouvrir `/` | Deux colonnes : gauche (1/3) avec tab 1 actif, droite (2/3) avec feuille A4 (blanche, ombragée) |
|
||||||
|
| G2 | Réduire la fenêtre sous 1024 px | Les panneaux s'empilent (gauche au-dessus de la droite) sans casse |
|
||||||
|
| G3 | Vérifier la barre d'app et le footer | Barre : logo, bouton « Imprimer / PDF », lien Documentation. Footer : lien Mentions légales (masqué à l'impression) |
|
||||||
|
|
||||||
|
### 3.2 Onglets 1 à 5 (zones de saisie)
|
||||||
|
|
||||||
|
| # | Action | Résultat attendu |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T1 | Cliquer sur les onglets 1→5 | L'onglet actif change ; le textarea affiche le contenu **de cet onglet uniquement** |
|
||||||
|
| T2 | Saisir du Markdown dans l'onglet 1 (`**Jean Dupont**`) | Rendu temps réel dans le **header** de la feuille A4 (gras) |
|
||||||
|
| T3 | Idem dans l'onglet 2 (nom, e-mail, site, GitHub en listes) | Rendu dans la section **État civil** (colonne 1/3) |
|
||||||
|
| T4 | Onglet 3 (formation ex. `### Master` + liste) | Rendu dans la section **Formations** |
|
||||||
|
| T5 | Onglet 4 | Rendu dans la section **Soft skills** |
|
||||||
|
| T6 | Onglet 5 | Rendu dans la section **Centres d'intérêt** |
|
||||||
|
| T7 | Basculer entre onglets après saisie | Chaque contenu est conservé, aucun écrasement croisé |
|
||||||
|
| T8 | Vider complètement une section | La section correspondante disparaît de la preview |
|
||||||
|
|
||||||
|
### 3.3 Expériences professionnelles
|
||||||
|
|
||||||
|
| # | Action | Résultat attendu |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| X1 | Cliquer sur le bouton **+** | Un formulaire (titre + contenu) apparaît, vide |
|
||||||
|
| X2 | Saisir titre + contenu puis **Enregistrer** | L'expérience apparaît dans la liste numérotée du panneau gauche (numéro 1) ET en tête de la section « Expériences professionnelles » |
|
||||||
|
| X3 | Ajouter une 2ᵉ expérience | Elle s'affiche **sous** la 1ʳᵉ ; les listes se renumérotent (1, 2… dans l'ordre de saisie) |
|
||||||
|
| X4 | Cliquer **Modifier** sur une entrée | Le formulaire se remplit avec titre + contenu de cette expérience (mode modification) |
|
||||||
|
| X5 | Modifier puis **Enregistrer** | Le rendu preview met à jour la section Expériences |
|
||||||
|
| X6 | Cliquer **Annuler** en cours de modification | Aucune modification appliquée |
|
||||||
|
| X7 | Cliquer **×** sur une entrée | Confirmation demandée → confirmer → l'expérience disparaît (panneau gauche + preview) |
|
||||||
|
| X8 | Supprimer la dernière expérience | La section affiche le message d'incitation « Ajoutez une expérience… » |
|
||||||
|
| X9 | Contenu Markdown d'une expérience | Rendu temps réel en HTML dans la preview |
|
||||||
|
|
||||||
|
### 3.4 Persistance (localStorage)
|
||||||
|
|
||||||
|
| # | Action | Résultat attendu |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P1 | Saisir du contenu dans 2-3 zones puis **recharger la page** (F5) | Tous les contenus et la liste des expériences sont restaurés |
|
||||||
|
| P2 | Vérifier en devtools → Application → localStorage | Une clé `cv-maker:v1` existe, au format JSON avec `version: 1` |
|
||||||
|
| P3 | Vider localStorage (ou supprimer la clé) puis recharger | L'application démarre avec des zones vides, **sans erreur** |
|
||||||
|
| P4 | Introduire un JSON corrompu dans la clé (`cv-maker:v1`) puis recharger | Pas de crash ; état par défaut restauré |
|
||||||
|
| P5 | Cliquer **Exporter JSON** | Un fichier `cv-maker.json` est téléchargé contenant le contenu saisi |
|
||||||
|
| P6 | Créer/exporter un CV, **Importer JSON** sur ce fichier après modification | Un second clic sur **Importer** (bouton/badge) remplace le contenu par celui du fichier (cas nominal validé) |
|
||||||
|
|
||||||
|
### 3.5 Impression / PDF
|
||||||
|
|
||||||
|
| # | Action | Résultat attendu |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M1 | Cliquer **Imprimer / PDF** | L'aperçu d'impression ne contient que la feuille A4 (pas de panneau de saisie ni toolbar) |
|
||||||
|
| M2 | Choisir « Enregistrer en PDF » avec format A4 | Un PDF d'une page (contenu court) ; mise en page 1/3–2/3 respectée |
|
||||||
|
| M3 | Format A4 par défaut | `@page size: A4` appliqué (aucun choix manuel requis) |
|
||||||
|
|
||||||
|
### 3.6 Rendu Markdown et sécurité
|
||||||
|
|
||||||
|
| # | Action | Résultat attendu |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S1 | Saisir tous les éléments supportés (titres ###, gras, liste `-`, lien `[t](url)`, code `` ` ``) | Rendu HTML correct dans la preview |
|
||||||
|
| S2 | Saisir `<img src=x onerror=alert(1)>` dans une zone | Aucune alerte/exécution ; la balise est neutralisée |
|
||||||
|
| S3 | Saisir `<script>alert(1)</script>` | Affiché sans exécution (ou supprimé) — pas d'alerte |
|
||||||
|
| S4 | Saisir `<a href="javascript:alert(1)">x</a>` | Lien inoffensif ou supprimé ; aucun dialogue |
|
||||||
|
|
||||||
|
### 3.7 Pages Documentation et Mentions légales
|
||||||
|
|
||||||
|
| # | Action | Résultat attendu |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 | Naviguer vers `/documentation` depuis la toolbar | Page d'aide affichée (prise en main + syntaxe Markdown) |
|
||||||
|
| D2 | Naviguer vers `/mentions-legales` depuis le footer | Page affichée avec les sections attendues (module `06-` §4), **sans** paragraphe contact |
|
||||||
|
| D3 | Retour vers l'éditeur | Lien retour fonctionnel ; les données du CV n'ont pas bougé |
|
||||||
|
|
||||||
|
### 3.8 PWA (recette avancée — build production requis)
|
||||||
|
|
||||||
|
| # | Action | Résultat attendu |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| W1 | `pnpm build && pnpm start` | Aucune erreur de build liée à Serwist |
|
||||||
|
| W2 | DevTools → Application | Service worker enregistré, manifest chargé |
|
||||||
|
| W3 | Clic « Installer l'application » | Installation proposée (icône et nom corrects) |
|
||||||
|
| W4 | Passer hors-ligne puis recharger `/` | La page (squelette + app shell) est servie par le cache |
|
||||||
|
| W5 | Données du CV hors-ligne | Le CV reste intact (localStorage) — pas de régression |
|
||||||
|
|
||||||
|
## 4. Hygiène du code (vérifications finales)
|
||||||
|
|
||||||
|
- [ ] `pnpm lint` sans erreur ni warning bloquant.
|
||||||
|
- [ ] `pnpm typecheck` sans erreur.
|
||||||
|
- [ ] `pnpm audit` sans vulnérabilité critique.
|
||||||
|
- [ ] Aucun `console.log` résiduel (à l'exception éventuelle du `console.warn` d'échec de sauvegarde documenté).
|
||||||
|
|
||||||
|
## 5. Évolutions testables (v2 — hors périmètre v1)
|
||||||
|
|
||||||
|
Dès leur mise en place, ajouter à la recette :
|
||||||
|
|
||||||
|
- Tests **Vitest** pour `lib/markdown.ts` (rendu + assainissement) et `lib/storage.ts` (parse/corruption) ;
|
||||||
|
- Mise en place de **drag & drop** des expériences (réordonnancement) avec tests correspondants ;
|
||||||
|
- Tests composants (React Testing Library) sur `CvTabs`, `ExperienceManager`, `CvPreview` (rendu conditionnel des sections vides).
|
||||||
|
|
||||||
|
## 6. Bilan de recette
|
||||||
|
|
||||||
|
| Critère global GOAL.md | Tests concernés | Statut final |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Desktop first, 2 colonnes 1/3–2/3 | G1, G2 | ☐ |
|
||||||
|
| Markdown → HTML temps réel | T2–T6, X9, S1 | ☐ |
|
||||||
|
| Preview A4 | G1, M1–M3 | ☐ |
|
||||||
|
| Persistance localStorage | P1–P5 | ☐ |
|
||||||
|
| Impression PDF | M1–M3 | ☐ |
|
||||||
|
| Expériences (+, liste, modif, ×) | X1–X9 | ☐ |
|
||||||
|
| Onglets 1-5 | T1–T8 | ☐ |
|
||||||
|
| Documentation + Mentions légales | D1–D3 | ☐ |
|
||||||
|
| PWA | W1–W5 | ☐ |
|
||||||
|
|
||||||
|
Tout critère ☐ non coché en livraison = anomalie à corriger avant livraison finale.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# 08 — Déploiement conteneurisé (Docker / DockerHub)
|
||||||
|
|
||||||
|
> Ce document est la **spécification** de la dockerisation de CV-Maker. Les fichiers `Dockerfile`, `docker-compose.yml` et `.dockerignore` seront créés lors de l'implémentation, **conformément** au contenu détaillé ci-dessous.
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
1. Packager l'application Next.js dans une **image Docker** de production reproductible.
|
||||||
|
2. Pouvoir **pousser cette image sur Docker Hub** (`docker push`) pour la distribuer et la déployer n'importe où (`docker run` / `docker compose up`).
|
||||||
|
3. Maîtriser la taille de l'image : seule la **sortie standalone** de Next.js est embarquée (pas le code source ni les dépendances de build).
|
||||||
|
|
||||||
|
## 2. Architecture de l'image (multi-stage)
|
||||||
|
|
||||||
|
```
|
||||||
|
node:20-alpine
|
||||||
|
│
|
||||||
|
├─ stage [deps] pnpm install --frozen-lockfile (lockfile seul)
|
||||||
|
│
|
||||||
|
├─ stage [builder] sources + node_modules → pnpm build
|
||||||
|
│ → produit : .next/standalone, .next/static, public/
|
||||||
|
│
|
||||||
|
└─ stage [runner] .next/standalone + .next/static + public/
|
||||||
|
→ node server.js (HOSTNAME=0.0.0.0 PORT=3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Stage | Image | Contenu | Rôle |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `deps` | `node:20-alpine` | `package.json` + `pnpm-lock.yaml` | Télécharge et fige les dépendances (cache de build efficace) |
|
||||||
|
| `builder` | `node:20-alpine` | sources + `node_modules` | Compile `pnpm build` ; produit l'artefact standalone |
|
||||||
|
| `runner` | `node:20-alpine` | `.next/standalone`, `.next/static`, `public/` | Exécution seule : image légère, aucun source ni tooling |
|
||||||
|
|
||||||
|
### Pourquoi `output: "standalone"` ?
|
||||||
|
|
||||||
|
Next.js trace à la compilation toutes les dépendances réellement utilisées et génère un `server.js` autonome dans `.next/standalone`. Cela permet une image de production **sans node_modules complète** (économie de l'ordre de ×5 à ×10 en taille).
|
||||||
|
|
||||||
|
### Pourquoi `node:20-alpine` ?
|
||||||
|
|
||||||
|
Base minimale (≈50 Mo) pour la phase runtime, cohérente avec le prérequis Node 20 du projet. Compatible glibc/musl — aucune dépendance native dans ce projet (marked/dompurify/lucide sont des packages JS purs).
|
||||||
|
|
||||||
|
## 3. Fichiers à créer — spécifications
|
||||||
|
|
||||||
|
### 3.1 `Dockerfile`
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# ── Étape de dépendances ────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.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"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes d'intégration :
|
||||||
|
- `corepack enable` active pnpm à partir du champ `packageManager` du `package.json` (écrit par `create-next-app`) → version de pnpm **figée**.
|
||||||
|
- `pnpm install --frozen-lockfile` : échec si le lockfile n'est pas à jour (déploiement déterministe).
|
||||||
|
- `public/` contient génère tout ce qui est généré pendant le build (ex. `sw.js` de Serwist) mais est copié depuis le builder → toujours cohérent.
|
||||||
|
|
||||||
|
### 3.2 `.dockerignore`
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
.DS_Store
|
||||||
|
```
|
||||||
|
|
||||||
|
Justification :
|
||||||
|
- `node_modules`, `.next`, `docs`, `*.md` : ne doivent **jamais** entrer dans le contexte de build (cache, rapidité, confidentialité — les secrets éventuels d'une doc `.env` ne sont pas copiés).
|
||||||
|
- Le contexte `docker build` doit contenir **uniquement** ce dont le builder a besoin : sources `app/`, `components/`, `lib/`, `public/`, `next.config.ts`, `tsconfig.json`, `package.json`, `pnpm-lock.yaml`.
|
||||||
|
|
||||||
|
### 3.3 `docker-compose.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
cv-maker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: cv-maker:local # nom local ; remplacé par <mon-user-dockerhub>/cv-maker:tag au push
|
||||||
|
container_name: cv-maker
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
- PORT=3000
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
- `wget` est fourni par busybox sur Alpine (utilisable pour le healthcheck).
|
||||||
|
- `127.0.0.1` (et non `localhost`) : busybox wget privilégie `::1` (IPv6) quand `/etc/hosts` définit `localhost` en dual-stack ; le serveur Next écoute en IPv4 (`0.0.0.0`) uniquement → pas de fallback, erreur `Connection refused`. L'adresse IP explicite évite ce piège (validé C6).
|
||||||
|
- Le **stockage des données reste dans le localStorage du navigateur** : le conteneur n'a besoin d'aucun volume persistant.
|
||||||
|
|
||||||
|
## 4. Workflow Docker Hub
|
||||||
|
|
||||||
|
### 4.1 Build et test local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build de l'image
|
||||||
|
docker build -t cv-maker:latest .
|
||||||
|
|
||||||
|
# Test rapide
|
||||||
|
docker run --rm -p 3000:3000 cv-maker:latest
|
||||||
|
# → http://localhost:3000
|
||||||
|
|
||||||
|
# Inspection (taille, couches)
|
||||||
|
docker images cv-maker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Publication sur Docker Hub
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Se connecter au registre
|
||||||
|
docker login
|
||||||
|
|
||||||
|
# 2. Taguer avec le namespace du compte (placeholder à remplacer)
|
||||||
|
docker tag cv-maker:latest <mon-user-dockerhub>/cv-maker:latest
|
||||||
|
|
||||||
|
# 3. Optionnel : tag avec version (semver)
|
||||||
|
docker tag cv-maker:latest <mon-user-dockerhub>/cv-maker:1.0.0
|
||||||
|
|
||||||
|
# 4. Pousser
|
||||||
|
docker push <mon-user-dockerhub>/cv-maker:latest
|
||||||
|
docker push <mon-user-dockerhub>/cv-maker:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
> Remplacez `<mon-user-dockerhub>` par votre identifiant Docker Hub. Le pattern `latest` + `1.0.0` (semver) est recommandé : `latest` pour les déploiements courants, un tag précis pour les retours arrière.
|
||||||
|
|
||||||
|
### 4.3 Déploiement depuis l'image publiée
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull <mon-user-dockerhub>/cv-maker:latest
|
||||||
|
docker run -d -p 3000:3000 --name cv-maker <mon-user-dockerhub>/cv-maker:latest
|
||||||
|
# ou via compose (après édition de image: dans docker-compose.yml)
|
||||||
|
docker compose up -d
|
||||||
|
docker compose down # arrêt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Recette de validation conteneurisée
|
||||||
|
|
||||||
|
| # | Action | Résultat attendu |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1 | `docker build -t cv-maker:latest .` | Build multi-stage sans erreur ; maintenant `deps` cache le layer npm |
|
||||||
|
| C2 | `docker images cv-maker` | Image < 200 Mo (standalone) |
|
||||||
|
| C3 | `docker run --rm -p 3000:3000 cv-maker:latest` puis ouvrir `localhost:3000` | L'éditeur s'affiche ; le PWA `sw.js` des réponses |
|
||||||
|
| C4 | Saisir du contenu, recharger | Persistance localStorage fonctionnelle dans le conteneur |
|
||||||
|
| C5 | `docker exec <ctr> wget -qO- localhost:3000/documentation` | HTTP 200, contenu de la page Documentation |
|
||||||
|
| C6 | `docker compose up -d` puis `docker compose ps` | Conteneur `healthy`, port mappé |
|
||||||
|
| C7 | `docker push <mon-user-dockerhub>/cv-maker:latest` | Image visible sur Docker Hub (page du compte) |
|
||||||
|
| C8 | `docker pull/run` depuis Docker Hub sur une autre machine | L'application démarre identiquement |
|
||||||
|
|
||||||
|
## 6. Dépannage
|
||||||
|
|
||||||
|
| Problème | Cause probable | Remède |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pnpm: command not found` dans le build | `corepack enable` avant `pnpm` | Vérifier l'ordre des instructions dans `deps` |
|
||||||
|
| `Error: Cannot find module server.js` | sortie standalone absente | Vérifier `output: "standalone"` dans `next.config.ts` avant `pnpm build` |
|
||||||
|
| `sw.js` (PWA) non servi | `public/` oublié dans le runner | Ajouter `COPY --from=builder /app/public ./public` |
|
||||||
|
| Port déjà occupé en local | un autre process sur 3000 | Changer la mappage : `-p 3001:3000` |
|
||||||
|
| Image trop lourde | `.dockerignore` absent/incomplet | Re-vérifier les exclusions `node_modules`, `.next` |
|
||||||
|
| `frozen-lockfile` échoue | `pnpm-lock.yaml` obsolète | Re-synchroniser en local (`pnpm install`) puis commit du lockfile |
|
||||||
|
|
||||||
|
## 7. Évolutions (non bloquantes v1)
|
||||||
|
|
||||||
|
- **CI/CD** : workflow GitHub Actions (ou GitLab CI) : stage `build` → `push` sur Docker Hub uniquement sur les tags/`main` ; secrets `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` stockés dans les paramètres du dépôt.
|
||||||
|
- **Multiplateformes** : `docker buildx build --platform linux/amd64,linux/arm64` pour ARM (Raspberry, Apple Silicon serveurs).
|
||||||
|
- **Scan de sécurité** : `docker scout cves <image>` avant chaque push.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Dossier de conception — CV-Maker
|
||||||
|
|
||||||
|
Dossier de conception détaillé du projet **CV-Maker**, application web de création de CV en temps réel.
|
||||||
|
|
||||||
|
## Sommaire
|
||||||
|
|
||||||
|
| Document | Contenu |
|
||||||
|
| --- | --- |
|
||||||
|
| [01-analyse-fonctionnelle.md](./01-analyse-fonctionnelle.md) | Analyse du besoin, fonctionnalités détaillées issues du GOAL.md, spécifications écran par écran |
|
||||||
|
| [02-architecture-technique.md](./02-architecture-technique.md) | Choix technique, architecture en couches, organisation des dossiers `app/`, `components/`, `lib/` |
|
||||||
|
| [03-modele-donnees.md](./03-modele-donnees.md) | Contracts TypeScript, schéma de données localStorage, hooks de persistance |
|
||||||
|
| [04-specifications-ui.md](./04-specifications-ui.md) | Arborescence des composants, design tokens Tailwind, contraintes du format A4, états UI |
|
||||||
|
| [05-etapes-realisation.md](./05-etapes-realisation.md) | Plan d'implémentation pas-à-pas : commandes exactes (pnpm), ordre des fichiers, livrables |
|
||||||
|
| [06-securite-rgpd.md](./06-securite-rgpd.md) | Sécurité (DOMPurify / XSS), traitement des données, contenu de la page Mentions légales |
|
||||||
|
| [07-plan-tests.md](./07-plan-tests.md) | Recette manuelle fonctionnelle, critères d'acceptation liés au GOAL.md |
|
||||||
|
| [08-deploiement-docker.md](./08-deploiement-docker.md) | Dockerisation : Dockerfile multi-stage, docker-compose, publication sur Docker Hub |
|
||||||
|
|
||||||
|
## Ordre de lecture conseillé
|
||||||
|
|
||||||
|
1. `01-analyse-fonctionnelle.md` — comprendre le produit
|
||||||
|
2. `02-architecture-technique.md` — comprendre la stack
|
||||||
|
3. `03-modele-donnees.md` — comprendre les données
|
||||||
|
4. `04-specifications-ui.md` — comprendre l'interface
|
||||||
|
5. `05-etapes-realisation.md` — implémenter (feuille de route)
|
||||||
|
6. `06-securite-rgpd.md` — traiter sécurité et conformité
|
||||||
|
7. `07-plan-tests.md` — valider la livraison
|
||||||
|
8. `08-deploiement-docker.md` — conteneuriser et publier l'image
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- Document source : [`GOAL.md`](../../GOAL.md)
|
||||||
|
- Documentation technique d'exploitation : [`README.md`](../../README.md)
|
||||||
@@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
|
|||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
// Service worker généré par @serwist/cli.
|
||||||
|
"public/sw*",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import {
|
||||||
|
createDefaultCVData,
|
||||||
|
type CVCareerData,
|
||||||
|
type CVTabId,
|
||||||
|
type Experience,
|
||||||
|
} from "@/lib/types/cv";
|
||||||
|
import { localStorageAdapter } from "@/lib/storage";
|
||||||
|
import { useLocalStorage } from "@/lib/hooks/useLocalStorage";
|
||||||
|
|
||||||
|
export interface UseCVDataReturn {
|
||||||
|
data: CVCareerData;
|
||||||
|
setText: (tab: CVTabId, value: string) => void;
|
||||||
|
addExperience: (titre: string, contenu: string) => void;
|
||||||
|
updateExperience: (id: string, titre: string, contenu: string) => void;
|
||||||
|
removeExperience: (id: string) => void;
|
||||||
|
resetCV: () => void;
|
||||||
|
importCV: (value: CVCareerData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = "Expérience";
|
||||||
|
|
||||||
|
export function useCVData(): UseCVDataReturn {
|
||||||
|
const [data, setData] = useLocalStorage<CVCareerData>(
|
||||||
|
localStorageAdapter,
|
||||||
|
createDefaultCVData(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const setText = useCallback(
|
||||||
|
(tab: CVTabId, value: string) => {
|
||||||
|
setData((prev) => ({ ...prev, [tab]: value }));
|
||||||
|
},
|
||||||
|
[setData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addExperience = useCallback(
|
||||||
|
(titre: string, contenu: string) => {
|
||||||
|
setData((prev) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const experience: Experience = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
titre: titre.trim() || DEFAULT_TITLE,
|
||||||
|
contenu,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
return { ...prev, experiences: [...prev.experiences, experience] };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateExperience = useCallback(
|
||||||
|
(id: string, titre: string, contenu: string) => {
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
experiences: prev.experiences.map((exp) =>
|
||||||
|
exp.id === id
|
||||||
|
? {
|
||||||
|
...exp,
|
||||||
|
titre: titre.trim() || DEFAULT_TITLE,
|
||||||
|
contenu,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
: exp,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[setData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeExperience = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
experiences: prev.experiences.filter((exp) => exp.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[setData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetCV = useCallback(() => {
|
||||||
|
setData(createDefaultCVData());
|
||||||
|
}, [setData]);
|
||||||
|
|
||||||
|
const importCV = useCallback((value: CVCareerData) => {
|
||||||
|
setData(value);
|
||||||
|
}, [setData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
setText,
|
||||||
|
addExperience,
|
||||||
|
updateExperience,
|
||||||
|
removeExperience,
|
||||||
|
resetCV,
|
||||||
|
importCV,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface StorageLike<T> {
|
||||||
|
load(): T | null;
|
||||||
|
save(value: T): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAVE_DELAY_MS = 60;
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(
|
||||||
|
storage: StorageLike<T>,
|
||||||
|
initialValue: T,
|
||||||
|
enable = true,
|
||||||
|
): [T, (value: T | ((prev: T) => T)) => void] {
|
||||||
|
const [value, setValue] = useState<T>(initialValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enable) return;
|
||||||
|
let disposed = false;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const stored = storage.load();
|
||||||
|
if (disposed || stored === null || stored === undefined) return;
|
||||||
|
setValue(stored);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Échec du chargement localStorage", error);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [storage, enable]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enable) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
storage.save(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Échec de la sauvegarde localStorage", error);
|
||||||
|
}
|
||||||
|
}, SAVE_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [storage, value, enable]);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { marked } from "marked";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
marked.setOptions({ gfm: true, breaks: true });
|
||||||
|
|
||||||
|
export function markdownToHtml(source: string): string {
|
||||||
|
const rawHtml = marked.parse(source, { async: false }) as string;
|
||||||
|
if (typeof window === "undefined") return rawHtml;
|
||||||
|
return DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
createDefaultCVData,
|
||||||
|
type CVCareerData,
|
||||||
|
} from "@/lib/types/cv";
|
||||||
|
|
||||||
|
export const STORAGE_KEY = "cv-maker:v1";
|
||||||
|
|
||||||
|
export interface CVStorageAdapter {
|
||||||
|
load(): CVCareerData | null;
|
||||||
|
save(data: CVCareerData): void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCVCareerData(value: unknown): value is CVCareerData {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const data = value as Record<string, unknown>;
|
||||||
|
if (data.version !== 1) return false;
|
||||||
|
if (!Array.isArray(data.experiences)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCVCareerData(value: unknown): CVCareerData | null {
|
||||||
|
if (!isCVCareerData(value)) return null;
|
||||||
|
return { ...createDefaultCVData(), ...value, experiences: value.experiences };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalStorageAdapter(): CVStorageAdapter {
|
||||||
|
return {
|
||||||
|
load() {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === null) return null;
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return normalizeCVCareerData(parsed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
save(data) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Échec de la sauvegarde localStorage", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localStorageAdapter = createLocalStorageAdapter();
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export type CVTabId =
|
||||||
|
| "accroche"
|
||||||
|
| "etatCivil"
|
||||||
|
| "formations"
|
||||||
|
| "softSkills"
|
||||||
|
| "centresInteret";
|
||||||
|
|
||||||
|
export const CV_TAB_ORDER: CVTabId[] = [
|
||||||
|
"accroche",
|
||||||
|
"etatCivil",
|
||||||
|
"formations",
|
||||||
|
"softSkills",
|
||||||
|
"centresInteret",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface CVTabMeta {
|
||||||
|
id: CVTabId;
|
||||||
|
numero: 1 | 2 | 3 | 4 | 5;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CV_TABS: Record<CVTabId, CVTabMeta> = {
|
||||||
|
accroche: { id: "accroche", numero: 1, label: "Accroche" },
|
||||||
|
etatCivil: { id: "etatCivil", numero: 2, label: "État civil" },
|
||||||
|
formations: { id: "formations", numero: 3, label: "Formations" },
|
||||||
|
softSkills: { id: "softSkills", numero: 4, label: "Soft skills" },
|
||||||
|
centresInteret: {
|
||||||
|
id: "centresInteret",
|
||||||
|
numero: 5,
|
||||||
|
label: "Centres d'intérêt",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Experience {
|
||||||
|
id: string;
|
||||||
|
titre: string;
|
||||||
|
contenu: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CVCareerData {
|
||||||
|
version: 1;
|
||||||
|
accroche: string;
|
||||||
|
etatCivil: string;
|
||||||
|
formations: string;
|
||||||
|
softSkills: string;
|
||||||
|
centresInteret: string;
|
||||||
|
experiences: Experience[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CVStorage = CVCareerData;
|
||||||
|
|
||||||
|
export function createDefaultCVData(): CVCareerData {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
accroche: "",
|
||||||
|
etatCivil: "",
|
||||||
|
formations: "",
|
||||||
|
softSkills: "",
|
||||||
|
centresInteret: "",
|
||||||
|
experiences: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
@@ -4,20 +4,30 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"dev:sw": "concurrently -p none 'serwist build --watch serwist.config.mjs' 'next dev'",
|
||||||
|
"build": "next build && serwist build serwist.config.mjs",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@serwist/next": "^9.5.12",
|
||||||
|
"dompurify": "^3.4.15",
|
||||||
|
"lucide-react": "^1.46.0",
|
||||||
|
"marked": "^18.0.13",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"serwist": "^9.5.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@serwist/cli": "^9.5.12",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"concurrently": "^10.0.5",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.5",
|
"eslint-config-next": "16.3.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
sharp: false
|
sharp: false
|
||||||
unrs-resolver: false
|
unrs-resolver: false
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 592 B |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@@ -0,0 +1,88 @@
|
|||||||
|
import { deflateSync } from "node:zlib";
|
||||||
|
import { writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
// Civetest: minimal solid-color PNG encoder (non-interlaced, 8-bit RGB)
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = ~0;
|
||||||
|
for (let n = 0; n < buf.length; n++) {
|
||||||
|
c ^= buf[n];
|
||||||
|
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
||||||
|
}
|
||||||
|
return ~c >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
const len = Buffer.alloc(4);
|
||||||
|
len.writeUInt32BE(data.length, 0);
|
||||||
|
const typeBuf = Buffer.from(type, "ascii");
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||||
|
return Buffer.concat([len, typeBuf, data, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple canvas: draw background + centered white square "page" motif
|
||||||
|
function draw(size) {
|
||||||
|
const bg = [37, 99, 235]; // accent #2563eb
|
||||||
|
const white = [255, 255, 255];
|
||||||
|
const rows = [];
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
const row = Buffer.alloc(1 + size * 3);
|
||||||
|
let p = 1;
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
let color = bg;
|
||||||
|
// white rounded "page / document" block centered
|
||||||
|
const margin = Math.round(size * 0.22);
|
||||||
|
const inner = size - margin * 2;
|
||||||
|
const xm = x - margin;
|
||||||
|
const ym = y - margin;
|
||||||
|
const radius = Math.round(size * 0.08);
|
||||||
|
if (
|
||||||
|
xm >= 0 &&
|
||||||
|
ym >= 0 &&
|
||||||
|
xm < inner &&
|
||||||
|
ym < inner &&
|
||||||
|
(xm <= radius || xm >= inner - radius || ym <= radius || ym >= inner - radius || Math.hypot(xm - radius, ym - radius) <= radius || Math.hypot(xm - (inner - radius), ym - radius) <= radius || Math.hypot(xm - radius, ym - (inner - radius)) <= radius || Math.hypot(xm - (inner - radius), ym - (inner - radius)) <= radius)
|
||||||
|
) {
|
||||||
|
color = white;
|
||||||
|
}
|
||||||
|
// blue horizontal bars to suggest "CV" lines
|
||||||
|
const barTop = Math.round(size * 0.45);
|
||||||
|
const barHeight = Math.round(size * 0.06);
|
||||||
|
if (ym >= barTop && ym < barTop + barHeight && xm > Math.round(size * 0.32) && xm < inner - Math.round(size * 0.25)) {
|
||||||
|
color = bg;
|
||||||
|
}
|
||||||
|
if (ym >= barTop + barHeight * 2 && ym < barTop + barHeight * 3 && xm > Math.round(size * 0.38) && xm < inner - Math.round(size * 0.1)) {
|
||||||
|
color = bg;
|
||||||
|
}
|
||||||
|
row[p++] = color[0];
|
||||||
|
row[p++] = color[1];
|
||||||
|
row[p++] = color[2];
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return Buffer.concat(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function png(size, file) {
|
||||||
|
const raw = draw(size);
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0);
|
||||||
|
ihdr.writeUInt32BE(size, 4);
|
||||||
|
ihdr[8] = 8; // bit depth
|
||||||
|
ihdr[9] = 2; // color type RGB
|
||||||
|
ihdr[10] = 0;
|
||||||
|
ihdr[11] = 0;
|
||||||
|
ihdr[12] = 0;
|
||||||
|
const png = Buffer.concat([
|
||||||
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||||
|
chunk("IHDR", ihdr),
|
||||||
|
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||||
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
writeFileSync(file, png);
|
||||||
|
console.log(`generated ${file} (${size}x${size})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync("./public/icons", { recursive: true });
|
||||||
|
png(192, "./public/icons/icon-192.png");
|
||||||
|
png(512, "./public/icons/icon-512.png");
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { serwist } from "@serwist/next/config";
|
||||||
|
|
||||||
|
export default serwist({
|
||||||
|
swSrc: "app/sw.ts",
|
||||||
|
swDest: "public/sw.js",
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
|
|||||||