Compare commits

...

3 Commits

Author SHA1 Message Date
stanig2106 4e9746c308 feat: ajoute le système i18n frontend (FR/EN)
- Crée js/i18n/i18n.js : module central avec t(), getLocale(), setLocale()
- Crée js/i18n/locales/fr.json et en.json : traductions FR et EN
- Crée js/i18n/applyTranslations.js : applique les data-i18n au DOM
- Crée js/i18n/langSwitcher.js : sélecteur de langue (boutons FR/EN)
- Modifie index.html : ajoute les attributs data-i18n sur tous les textes
- Modifie js/main.js : intègre i18n et relance l'application des traductions
- Modifie css/form.css : styles pour le sélecteur de langue
- Persistance de la langue dans localStorage (clé cv_lang)
2026-04-05 22:43:44 +01:00
stanig2106 f0cf9a605f Merge pull request 'refactor: factoriser le code en plusieurs fichiers' (#9) from feat/refactor-multi-files into main
Reviewed-on: #9
2026-04-05 13:40:30 +00:00
stanig2106 792542a71f refactor: factoriser le code en plusieurs fichiers (#8)
- Déplace les styles inline vers css/form.css et css/cv.css
- Crée js/main.js comme point d'entrée ES Module (type="module")
- Extrait la logique identité dans js/sections/identite.js
- Ajoute les stubs js/sections/{competences,formation,experience,hobbies}.js
- Crée les dossiers sections/{identite,competences,formation,experience,hobbies}/
  avec .gitkeep et README décrivant le contenu prévu
- Met à jour index.html : liens CSS séparés, script type=module
- Met à jour README.md : avertissement ES Modules, structure des fichiers
2026-04-05 13:55:45 +01:00
24 changed files with 623 additions and 146 deletions
+58 -1
View File
@@ -1,3 +1,60 @@
# cv-generator
Application web pour générer des CV professionnels en PDF
Application web pour générer des CV professionnels.
---
## 🚀 Lancer le projet
> ⚠️ Le projet utilise des **ES Modules natifs** (`import`/`export`).
> Les navigateurs bloquent les modules via le protocole `file://` (CORS).
> **Il faut obligatoirement servir le projet depuis un serveur local.**
```bash
# Option 1 — serveur rapide avec Node.js
npx serve .
# Option 2 — extension Live Server dans VSCode
# Clic droit sur index.html → "Open with Live Server"
```
Puis ouvrir [http://localhost:3000](http://localhost:3000) (ou le port indiqué).
> ❌ Ne pas ouvrir `index.html` directement par double-clic — les modules ne se chargeront pas.
---
## 📁 Structure du projet
```
css/
form.css # Styles de la page et du formulaire
cv.css # Styles du rendu CV (à venir)
js/
main.js # Point d'entrée : listeners DOM + orchestration
sections/
identite.js # Collect + render de la section Identité
competences.js # Stub — Section Compétences (à implémenter)
formation.js # Stub — Section Formation (à implémenter)
experience.js # Stub — Section Expérience pro (à implémenter)
hobbies.js # Stub — Section Hobbies (à implémenter)
sections/
identite/ # Fragment HTML de la section Identité
competences/ # Stub — à venir
formation/ # Stub — à venir
experience/ # Stub — à venir
hobbies/ # Stub — à venir
index.html
```
---
## 🗺️ Sections à venir
| Section | Dossier | Module JS | Statut |
|---------------|--------------------------|---------------------------------|--------------|
| Identité | `sections/identite/` | `js/sections/identite.js` | ✅ Actif |
| Compétences | `sections/competences/` | `js/sections/competences.js` | 🚧 Stub |
| Formation | `sections/formation/` | `js/sections/formation.js` | 🚧 Stub |
| Expérience | `sections/experience/` | `js/sections/experience.js` | 🚧 Stub |
| Hobbies | `sections/hobbies/` | `js/sections/hobbies.js` | 🚧 Stub |
+90
View File
@@ -0,0 +1,90 @@
/* =============================================
cv.css — Styles du rendu CV généré
============================================= */
/* ── Conteneur principal du CV ── */
.cv-container {
background: #fff;
color: #1a1a2e;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
margin-top: 32px;
display: none; /* masqué par défaut, affiché après génération */
}
.cv-container.visible {
display: block;
}
/* ── En-tête du CV ── */
.cv-header {
background: #e94560;
color: #fff;
padding: 32px;
}
.cv-header h2 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 4px;
}
.cv-header .cv-titre {
font-size: 1rem;
opacity: 0.9;
margin-bottom: 12px;
}
.cv-header .cv-contacts {
font-size: 0.85rem;
opacity: 0.85;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
/* ── Corps du CV ── */
.cv-body {
padding: 28px 32px;
}
.cv-section {
margin-bottom: 28px;
}
.cv-section h3 {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #e94560;
border-bottom: 2px solid #e94560;
padding-bottom: 6px;
margin-bottom: 14px;
}
/* ── Barres de compétences ── */
.skill-bar-container {
margin-bottom: 10px;
}
.skill-name {
font-size: 0.9rem;
margin-bottom: 4px;
color: #333;
}
.skill-bar {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.skill-level {
height: 100%;
background: #e94560;
border-radius: 3px;
transition: width 0.4s ease;
}
+161
View File
@@ -0,0 +1,161 @@
/* =============================================
form.css — Styles de la page et du formulaire
============================================= */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #0f0f0f;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
padding: 20px;
}
/* ── Layout ── */
.container {
max-width: 520px;
width: 100%;
}
header {
text-align: center;
margin-bottom: 32px;
}
h1 {
font-size: 2.2rem;
font-weight: 700;
color: #e94560;
margin-bottom: 10px;
}
header p {
color: #a0a0b0;
font-size: 0.95rem;
}
footer {
text-align: center;
margin-top: 28px;
font-size: 0.8rem;
color: #4a4a6a;
}
/* ── Card ── */
.card {
background: #16213e;
border: 1px solid #2a2a4a;
border-radius: 12px;
padding: 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* ── Formulaire ── */
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 0.85rem;
color: #a0a0b0;
margin-bottom: 6px;
font-weight: 500;
letter-spacing: 0.03em;
}
input {
width: 100%;
padding: 11px 14px;
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input::placeholder {
color: #4a4a6a;
}
input:focus {
border-color: #e94560;
box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.15);
}
/* ── Bouton ── */
.btn-generate {
width: 100%;
background: #e94560;
color: #fff;
border: none;
padding: 13px;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
transition: background 0.2s ease;
}
.btn-generate:hover {
background: #f05a73;
}
/* ── Message de succès ── */
.success-message {
display: none;
color: #4caf50;
text-align: center;
padding-top: 16px;
font-size: 0.95rem;
}
/* ── Header top row ── */
.header-top {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 10px;
}
/* ── Language switcher ── */
.lang-switcher {
display: flex;
gap: 6px;
}
.lang-btn {
background: transparent;
border: 1px solid #2a2a4a;
color: #a0a0b0;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.05em;
transition: border-color 0.2s ease, color 0.2s ease;
}
.lang-btn:hover {
border-color: #e94560;
color: #e0e0e0;
}
.lang-btn.active {
border-color: #e94560;
color: #e94560;
}
+28 -145
View File
@@ -4,138 +4,31 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CV Generator</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #0f0f0f;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
padding: 20px;
}
.container {
max-width: 520px;
width: 100%;
}
header {
text-align: center;
margin-bottom: 32px;
}
h1 {
font-size: 2.2rem;
font-weight: 700;
color: #e94560;
margin-bottom: 10px;
}
header p {
color: #a0a0b0;
font-size: 0.95rem;
}
.card {
background: #16213e;
border: 1px solid #2a2a4a;
border-radius: 12px;
padding: 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 0.85rem;
color: #a0a0b0;
margin-bottom: 6px;
font-weight: 500;
letter-spacing: 0.03em;
}
input {
width: 100%;
padding: 11px 14px;
background: #1a1a2e;
border: 1px solid #2a2a4a;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input::placeholder {
color: #4a4a6a;
}
input:focus {
border-color: #e94560;
box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.15);
}
.btn-generate {
width: 100%;
background: #e94560;
color: #fff;
border: none;
padding: 13px;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
transition: background 0.2s ease;
}
.btn-generate:hover {
background: #f05a73;
}
.success-message {
display: none;
color: #4caf50;
text-align: center;
padding-top: 16px;
font-size: 0.95rem;
}
footer {
text-align: center;
margin-top: 28px;
font-size: 0.8rem;
color: #4a4a6a;
}
</style>
<link rel="stylesheet" href="css/form.css">
<link rel="stylesheet" href="css/cv.css">
</head>
<body>
<div class="container">
<header>
<h1>CV Generator</h1>
<p>Remplissez vos informations pour générer votre CV</p>
<div class="header-top">
<h1 data-i18n="header.title">CV Generator</h1>
<div id="lang-switcher" class="lang-switcher" aria-label="Language switcher"></div>
</div>
<p data-i18n="header.subtitle">Remplissez vos informations pour générer votre CV</p>
</header>
<div class="card">
<form id="cv-form" novalidate>
<!-- ── Section Identité ── -->
<!-- Voir sections/identite/identite.html pour le découpage complet -->
<div class="form-group">
<label for="nom">Nom</label>
<label for="nom" data-i18n="form.nom">Nom</label>
<input
type="text"
id="nom"
name="nom"
data-i18n-placeholder="form.nom_placeholder"
placeholder="Dupont"
autocomplete="family-name"
required
@@ -145,11 +38,12 @@
</div>
<div class="form-group">
<label for="prenom">Prénom</label>
<label for="prenom" data-i18n="form.prenom">Prénom</label>
<input
type="text"
id="prenom"
name="prenom"
data-i18n-placeholder="form.prenom_placeholder"
placeholder="Jean"
autocomplete="given-name"
required
@@ -159,11 +53,12 @@
</div>
<div class="form-group">
<label for="email">Email</label>
<label for="email" data-i18n="form.email">Email</label>
<input
type="email"
id="email"
name="email"
data-i18n-placeholder="form.email_placeholder"
placeholder="jean.dupont@email.com"
autocomplete="email"
required
@@ -172,11 +67,12 @@
</div>
<div class="form-group">
<label for="titre">Titre du poste</label>
<label for="titre" data-i18n="form.titre">Titre du poste</label>
<input
type="text"
id="titre"
name="titre"
data-i18n-placeholder="form.titre_placeholder"
placeholder="Développeur Full Stack"
autocomplete="organization-title"
required
@@ -185,37 +81,24 @@
>
</div>
<button type="submit" class="btn-generate">Générer mon CV</button>
<!-- ── Futures sections (à venir) ──
- Formation → sections/formation/
- Expérience → sections/experience/
- Compétences → sections/competences/
- Hobbies → sections/hobbies/
-->
<button type="submit" class="btn-generate" data-i18n="form.submit">Générer mon CV</button>
<p class="success-message" id="success"></p>
</form>
</div>
<footer>
<p>CV Generator &copy; 2026</p>
<p data-i18n="footer">CV Generator &copy; 2026</p>
</footer>
</div>
<script>
document.getElementById('cv-form').addEventListener('submit', function(e) {
e.preventDefault();
const nom = document.getElementById('nom').value.trim();
const prenom = document.getElementById('prenom').value.trim();
const email = document.getElementById('email').value.trim();
const titre = document.getElementById('titre').value.trim();
if (!nom || !prenom || !email || !titre) return;
const formData = { nom, prenom, email, titre };
localStorage.setItem('cvData', JSON.stringify(formData));
const success = document.getElementById('success');
success.textContent = `CV prêt à être généré pour ${prenom} ${nom} !`;
success.style.display = 'block';
console.log('Données du CV :', formData);
});
</script>
<script type="module" src="js/main.js"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import { t } from './i18n.js';
export function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.dataset.i18nPlaceholder);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset.i18nTitle);
});
}
+35
View File
@@ -0,0 +1,35 @@
import fr from './locales/fr.json' assert { type: 'json' };
import en from './locales/en.json' assert { type: 'json' };
const LOCALES = { fr, en };
const SUPPORTED = Object.keys(LOCALES);
const STORAGE_KEY = 'cv_lang';
let currentLocale = localStorage.getItem(STORAGE_KEY) || 'fr';
if (!SUPPORTED.includes(currentLocale)) currentLocale = 'fr';
function resolve(obj, path) {
return path.split('.').reduce((acc, key) => acc?.[key], obj);
}
export function t(key, params = {}) {
const messages = LOCALES[currentLocale] ?? LOCALES.fr;
let str = resolve(messages, key) ?? resolve(LOCALES.fr, key) ?? key;
return str.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
}
export function getLocale() {
return currentLocale;
}
export function setLocale(lang) {
if (!SUPPORTED.includes(lang)) return;
currentLocale = lang;
localStorage.setItem(STORAGE_KEY, lang);
document.documentElement.lang = lang;
document.dispatchEvent(new CustomEvent('localechange', { detail: { lang } }));
}
export function getSupportedLocales() {
return SUPPORTED;
}
+17
View File
@@ -0,0 +1,17 @@
import { getLocale, setLocale, getSupportedLocales } from './i18n.js';
export function initLangSwitcher() {
const switcher = document.getElementById('lang-switcher');
if (!switcher) return;
const supported = getSupportedLocales();
switcher.innerHTML = supported
.map(lang => `<button class="lang-btn${lang === getLocale() ? ' active' : ''}" data-lang="${lang}">${lang.toUpperCase()}</button>`)
.join('');
switcher.addEventListener('click', (e) => {
const btn = e.target.closest('.lang-btn');
if (!btn) return;
setLocale(btn.dataset.lang);
});
}
+20
View File
@@ -0,0 +1,20 @@
{
"header": {
"title": "CV Generator",
"subtitle": "Fill in your details to generate your CV"
},
"form": {
"nom": "Last name",
"nom_placeholder": "Smith",
"prenom": "First name",
"prenom_placeholder": "John",
"email": "Email",
"email_placeholder": "john.smith@email.com",
"titre": "Job title",
"titre_placeholder": "Full Stack Developer",
"submit": "Generate my CV"
},
"success": "CV ready to be generated for {prenom} {nom}!",
"footer": "CV Generator © 2026",
"lang_switcher_label": "Language"
}
+20
View File
@@ -0,0 +1,20 @@
{
"header": {
"title": "CV Generator",
"subtitle": "Remplissez vos informations pour générer votre CV"
},
"form": {
"nom": "Nom",
"nom_placeholder": "Dupont",
"prenom": "Prénom",
"prenom_placeholder": "Jean",
"email": "Email",
"email_placeholder": "jean.dupont@email.com",
"titre": "Titre du poste",
"titre_placeholder": "Développeur Full Stack",
"submit": "Générer mon CV"
},
"success": "CV prêt à être généré pour {prenom} {nom} !",
"footer": "CV Generator © 2026",
"lang_switcher_label": "Langue"
}
+44
View File
@@ -0,0 +1,44 @@
import { collectIdentite, renderIdentite } from './sections/identite.js';
import { collectCompetences, renderCompetences } from './sections/competences.js';
import { collectFormation, renderFormation } from './sections/formation.js';
import { collectExperience, renderExperience } from './sections/experience.js';
import { collectHobbies, renderHobbies } from './sections/hobbies.js';
import { t, getLocale } from './i18n/i18n.js';
import { applyTranslations } from './i18n/applyTranslations.js';
import { initLangSwitcher } from './i18n/langSwitcher.js';
function generateCV() {
const identite = collectIdentite();
if (!identite.nom || !identite.prenom || !identite.email || !identite.titre) return;
renderIdentite(identite);
renderCompetences(collectCompetences());
renderFormation(collectFormation());
renderExperience(collectExperience());
renderHobbies(collectHobbies());
localStorage.setItem('cvData', JSON.stringify(identite));
const success = document.getElementById('success');
if (success) {
success.textContent = t('success', { prenom: identite.prenom, nom: identite.nom });
success.style.display = 'block';
}
}
document.addEventListener('DOMContentLoaded', () => {
document.documentElement.lang = getLocale();
applyTranslations();
initLangSwitcher();
document.addEventListener('localechange', () => {
applyTranslations();
initLangSwitcher();
});
document.getElementById('cv-form')?.addEventListener('submit', (e) => {
e.preventDefault();
generateCV();
});
});
+5
View File
@@ -0,0 +1,5 @@
// Stub — Section Compétences
// À implémenter : collectCompetences(), addCompetence(), renderCompetences()
export function collectCompetences() { return []; }
export function renderCompetences(_competences) { /* no-op */ }
+5
View File
@@ -0,0 +1,5 @@
// Stub — Section Expérience professionnelle
// À implémenter : collectExperience(), addExperienceEntry(), renderExperience()
export function collectExperience() { return []; }
export function renderExperience(_entries) { /* no-op */ }
+5
View File
@@ -0,0 +1,5 @@
// Stub — Section Formation
// À implémenter : collectFormation(), addFormationEntry(), renderFormation()
export function collectFormation() { return []; }
export function renderFormation(_entries) { /* no-op */ }
+5
View File
@@ -0,0 +1,5 @@
// Stub — Section Centres d'intérêt
// À implémenter : collectHobbies(), addHobby(), renderHobbies()
export function collectHobbies() { return []; }
export function renderHobbies(_hobbies) { /* no-op */ }
+30
View File
@@ -0,0 +1,30 @@
/**
* identite.js — Section Identité
* Collecte les champs nom, prénom, email, titre depuis le formulaire.
*/
/**
* @returns {{ nom: string, prenom: string, email: string, titre: string }}
*/
export function collectIdentite() {
const get = (id) => document.getElementById(id)?.value.trim() ?? '';
return {
nom: get('nom'),
prenom: get('prenom'),
email: get('email'),
titre: get('titre'),
};
}
/**
* @param {{ nom: string, prenom: string, email: string, titre: string }} identite
*/
export function renderIdentite(identite) {
const set = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
set('cv-nom', `${identite.prenom} ${identite.nom}`);
set('cv-titre', identite.titre);
set('cv-email', identite.email);
}
View File
+7
View File
@@ -0,0 +1,7 @@
# Section : competences
> 🚧 À implémenter — cette section sera ajoutée dans une prochaine itération.
## Contenu prévu
- Compétences techniques (langages, outils…)
- Compétences transverses (langues, soft skills…)
View File
+9
View File
@@ -0,0 +1,9 @@
# Section : experience
> 🚧 À implémenter — cette section sera ajoutée dans une prochaine itération.
## Contenu prévu
- Poste occupé
- Entreprise
- Période (début / fin)
- Description des missions
View File
+8
View File
@@ -0,0 +1,8 @@
# Section : formation
> 🚧 À implémenter — cette section sera ajoutée dans une prochaine itération.
## Contenu prévu
- Diplôme / intitulé
- Établissement
- Année d'obtention
View File
+6
View File
@@ -0,0 +1,6 @@
# Section : hobbies
> 🚧 À implémenter — cette section sera ajoutée dans une prochaine itération.
## Contenu prévu
- Liste des centres d'intérêt / loisirs
+55
View File
@@ -0,0 +1,55 @@
<!-- Section Identité : champs nom, prénom, email, titre du poste -->
<div class="form-group">
<label for="nom">Nom</label>
<input
type="text"
id="nom"
name="nom"
placeholder="Dupont"
autocomplete="family-name"
required
minlength="1"
aria-required="true"
>
</div>
<div class="form-group">
<label for="prenom">Prénom</label>
<input
type="text"
id="prenom"
name="prenom"
placeholder="Jean"
autocomplete="given-name"
required
minlength="1"
aria-required="true"
>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
placeholder="jean.dupont@email.com"
autocomplete="email"
required
aria-required="true"
>
</div>
<div class="form-group">
<label for="titre">Titre du poste</label>
<input
type="text"
id="titre"
name="titre"
placeholder="Développeur Full Stack"
autocomplete="organization-title"
required
minlength="1"
aria-required="true"
>
</div>