feat: ajouter le système i18n frontend (FR/EN)
This commit is contained in:
+40
-1
@@ -31,11 +31,50 @@ header {
|
|||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-sep {
|
||||||
|
color: #4a4a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #4a4a6a;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 4px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover {
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn.active {
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 2.2rem;
|
font-size: 2.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #e94560;
|
color: #e94560;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
header p {
|
header p {
|
||||||
|
|||||||
+19
-8
@@ -10,8 +10,15 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>CV Generator</h1>
|
<div class="header-top">
|
||||||
<p>Remplissez vos informations pour générer votre CV</p>
|
<h1 data-i18n="header.title">CV Generator</h1>
|
||||||
|
<div class="lang-switcher">
|
||||||
|
<button class="lang-btn" data-lang="fr">FR</button>
|
||||||
|
<span class="lang-sep">|</span>
|
||||||
|
<button class="lang-btn" data-lang="en">EN</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p data-i18n="header.subtitle">Remplissez vos informations pour générer votre CV</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -20,11 +27,12 @@
|
|||||||
<!-- ── Section Identité ── -->
|
<!-- ── Section Identité ── -->
|
||||||
<!-- Voir sections/identite/identite.html pour le découpage complet -->
|
<!-- Voir sections/identite/identite.html pour le découpage complet -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="nom">Nom</label>
|
<label for="nom" data-i18n="form.nom">Nom</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="nom"
|
id="nom"
|
||||||
name="nom"
|
name="nom"
|
||||||
|
data-i18n-placeholder="form.nomPlaceholder"
|
||||||
placeholder="Dupont"
|
placeholder="Dupont"
|
||||||
autocomplete="family-name"
|
autocomplete="family-name"
|
||||||
required
|
required
|
||||||
@@ -34,11 +42,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="prenom">Prénom</label>
|
<label for="prenom" data-i18n="form.prenom">Prénom</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="prenom"
|
id="prenom"
|
||||||
name="prenom"
|
name="prenom"
|
||||||
|
data-i18n-placeholder="form.prenomPlaceholder"
|
||||||
placeholder="Jean"
|
placeholder="Jean"
|
||||||
autocomplete="given-name"
|
autocomplete="given-name"
|
||||||
required
|
required
|
||||||
@@ -48,11 +57,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email" data-i18n="form.email">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
|
data-i18n-placeholder="form.emailPlaceholder"
|
||||||
placeholder="jean.dupont@email.com"
|
placeholder="jean.dupont@email.com"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
required
|
required
|
||||||
@@ -61,11 +71,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="titre">Titre du poste</label>
|
<label for="titre" data-i18n="form.titre">Titre du poste</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="titre"
|
id="titre"
|
||||||
name="titre"
|
name="titre"
|
||||||
|
data-i18n-placeholder="form.titrePlaceholder"
|
||||||
placeholder="Développeur Full Stack"
|
placeholder="Développeur Full Stack"
|
||||||
autocomplete="organization-title"
|
autocomplete="organization-title"
|
||||||
required
|
required
|
||||||
@@ -81,14 +92,14 @@
|
|||||||
- Hobbies → sections/hobbies/
|
- Hobbies → sections/hobbies/
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<button type="submit" class="btn-generate">Générer mon CV</button>
|
<button type="submit" class="btn-generate" data-i18n="form.submit">Générer mon CV</button>
|
||||||
|
|
||||||
<p class="success-message" id="success"></p>
|
<p class="success-message" id="success"></p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>CV Generator © 2026</p>
|
<p data-i18n="footer">CV Generator © 2026</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
const SUPPORTED = ['fr', 'en'];
|
||||||
|
const FALLBACK = 'fr';
|
||||||
|
const STORAGE_KEY = 'cv-lang';
|
||||||
|
|
||||||
|
let translations = {};
|
||||||
|
let currentLang = FALLBACK;
|
||||||
|
|
||||||
|
function detectLang() {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored && SUPPORTED.includes(stored)) return stored;
|
||||||
|
const browser = (navigator.language || '').slice(0, 2).toLowerCase();
|
||||||
|
return SUPPORTED.includes(browser) ? browser : FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTranslations(lang) {
|
||||||
|
const res = await fetch(`js/locales/${lang}.json`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a dot-notation key like "form.nom"
|
||||||
|
function resolve(key) {
|
||||||
|
return key.split('.').reduce((obj, k) => obj?.[k], translations) ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace {{var}} placeholders
|
||||||
|
export function t(key, vars = {}) {
|
||||||
|
let str = resolve(key);
|
||||||
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
|
str = str.replaceAll(`{{${k}}}`, v);
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLang() {
|
||||||
|
return currentLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply translations to all elements with [data-i18n] and [data-i18n-placeholder]
|
||||||
|
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.documentElement.lang = currentLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeLanguage(lang) {
|
||||||
|
if (!SUPPORTED.includes(lang)) return;
|
||||||
|
translations = await loadTranslations(lang);
|
||||||
|
currentLang = lang;
|
||||||
|
localStorage.setItem(STORAGE_KEY, lang);
|
||||||
|
applyTranslations();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initI18n() {
|
||||||
|
const lang = detectLang();
|
||||||
|
await changeLanguage(lang);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"title": "CV Generator",
|
||||||
|
"subtitle": "Fill in your details to generate your CV"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"nom": "Last name",
|
||||||
|
"nomPlaceholder": "Smith",
|
||||||
|
"prenom": "First name",
|
||||||
|
"prenomPlaceholder": "John",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "john.smith@email.com",
|
||||||
|
"titre": "Job title",
|
||||||
|
"titrePlaceholder": "Full Stack Developer",
|
||||||
|
"submit": "Generate my CV"
|
||||||
|
},
|
||||||
|
"success": "CV ready to be generated for {{prenom}} {{nom}}!",
|
||||||
|
"footer": "CV Generator © 2026"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"title": "CV Generator",
|
||||||
|
"subtitle": "Remplissez vos informations pour générer votre CV"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"nom": "Nom",
|
||||||
|
"nomPlaceholder": "Dupont",
|
||||||
|
"prenom": "Prénom",
|
||||||
|
"prenomPlaceholder": "Jean",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "jean.dupont@email.com",
|
||||||
|
"titre": "Titre du poste",
|
||||||
|
"titrePlaceholder": "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"
|
||||||
|
}
|
||||||
+20
-2
@@ -3,6 +3,7 @@ import { collectCompetences, renderCompetences } from './sections/competences.js
|
|||||||
import { collectFormation, renderFormation } from './sections/formation.js';
|
import { collectFormation, renderFormation } from './sections/formation.js';
|
||||||
import { collectExperience, renderExperience } from './sections/experience.js';
|
import { collectExperience, renderExperience } from './sections/experience.js';
|
||||||
import { collectHobbies, renderHobbies } from './sections/hobbies.js';
|
import { collectHobbies, renderHobbies } from './sections/hobbies.js';
|
||||||
|
import { initI18n, t, changeLanguage, getLang } from './i18n.js';
|
||||||
|
|
||||||
function generateCV() {
|
function generateCV() {
|
||||||
const identite = collectIdentite();
|
const identite = collectIdentite();
|
||||||
@@ -19,12 +20,29 @@ function generateCV() {
|
|||||||
|
|
||||||
const success = document.getElementById('success');
|
const success = document.getElementById('success');
|
||||||
if (success) {
|
if (success) {
|
||||||
success.textContent = `CV prêt à être généré pour ${identite.prenom} ${identite.nom} !`;
|
success.textContent = t('success', { prenom: identite.prenom, nom: identite.nom });
|
||||||
success.style.display = 'block';
|
success.style.display = 'block';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
function updateActiveLangButton() {
|
||||||
|
const lang = getLang();
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.lang === lang);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await initI18n();
|
||||||
|
updateActiveLangButton();
|
||||||
|
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await changeLanguage(btn.dataset.lang);
|
||||||
|
updateActiveLangButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('cv-form')?.addEventListener('submit', (e) => {
|
document.getElementById('cv-form')?.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
generateCV();
|
generateCV();
|
||||||
|
|||||||
Reference in New Issue
Block a user