{% extends 'application/whileresume/website/cv-public/base.html.twig' %}{% set show_loader = true %}{% block title %}{{ 'generate2.theme.page_title'|trans({}, 'whr-public') }} · WhileResume{% endblock %}{% block extra_css %} .theme-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 8px; } .theme-card { position: relative; padding: 24px 22px; border: 2px solid var(--ink-10); border-radius: var(--r-lg); cursor: pointer; transition: all 0.3s var(--ease); background: var(--white); } .theme-card:hover { border-color: var(--ink-20); transform: translateY(-2px); } .theme-card.selected { border-color: var(--violet-deep); background: var(--violet-ultra); box-shadow: 0 8px 28px var(--violet-glow); } .theme-card-ico { width: 44px; height: 44px; border-radius: var(--r-sm); background: var(--violet-soft); color: var(--violet-dark); display: flex; align-items: center; justify-content: center; margin-bottom: 14px; transition: all 0.25s; } .theme-card.selected .theme-card-ico { background: var(--violet-deep); color: white; } .theme-card-title { font-size: 1rem; font-weight: 600; margin-bottom: 4px; } .theme-card-desc { font-size: 0.82rem; color: var(--ink-60); line-height: 1.5; } .theme-check { position: absolute; top: 14px; right: 14px; width: 22px; height: 22px; border-radius: 50%; background: var(--violet-deep); color: white; display: flex; align-items: center; justify-content: center; opacity: 0; transform: scale(0.5); transition: all 0.3s var(--ease-spring); } .theme-card.selected .theme-check { opacity: 1; transform: scale(1); } /* ═══════════════════════════════════ COULEURS EN CERCLES ═══════════════════════════════════ */ .colors-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); gap: 14px; } .color-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; } .color-swatch { width: 52px; height: 52px; border-radius: 50%; cursor: pointer; position: relative; border: 3px solid transparent; box-shadow: 0 2px 8px rgba(0,0,0,0.08); transition: all 0.3s var(--ease-spring); display: flex; align-items: center; justify-content: center; } .color-swatch:hover { transform: scale(1.12); box-shadow: 0 6px 16px rgba(0,0,0,0.15); } .color-swatch.selected { transform: scale(1.15); border-color: var(--white); box-shadow: 0 0 0 3px var(--ink), 0 6px 18px rgba(0,0,0,0.2); } .color-swatch .check { opacity: 0; transform: scale(0.4); transition: all 0.25s var(--ease-spring); color: white; } .color-swatch.selected .check { opacity: 1; transform: scale(1); } .color-label { font-size: 0.72rem; color: var(--ink-60); font-weight: 500; text-align: center; } .color-swatch.selected + .color-label { color: var(--ink); font-weight: 600; } /* ═══════════════════════════════════ LANGUES EN BOUTONS PILL ═══════════════════════════════════ */ .lang-grid { display: flex; flex-wrap: wrap; gap: 8px; } .lang-btn { padding: 10px 18px; border: 1.5px solid var(--ink-10); background: var(--white); border-radius: var(--r-full); font-family: var(--font-body); font-size: 0.86rem; font-weight: 500; color: var(--ink-60); cursor: pointer; transition: all 0.2s var(--ease); user-select: none; } .lang-btn:hover { border-color: var(--ink-20); color: var(--ink-80); transform: translateY(-1px); } .lang-btn.selected { background: var(--violet-deep); border-color: var(--violet-deep); color: white; box-shadow: 0 4px 14px var(--violet-glow); } .lang-btn.selected:hover { background: var(--violet-dark); transform: translateY(-1px); }{% endblock %}{% block body %} <div class="fade-up"> <div class="badge"><span class="badge-num">2</span> {{ 'generate2.theme.badge'|trans({}, 'whr-public') }}</div> <h1 class="stitle">{{ 'generate2.theme.title_part1'|trans({}, 'whr-public') }}<br><em>{{ 'generate2.theme.title_part2'|trans({}, 'whr-public') }}</em></h1> <p class="sdesc">{{ 'generate2.theme.subtitle'|trans({}, 'whr-public') }}</p> </div> <div class="fade-up d1"> <div class="section-label">{{ 'generate2.theme.section_template'|trans({}, 'whr-public') }}</div> <div class="theme-grid"> <div class="theme-card" data-theme="simple"> <div class="theme-card-ico"> <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"> <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/> <polyline points="14 2 14 8 20 8"/> <line x1="16" y1="13" x2="8" y2="13"/> <line x1="16" y1="17" x2="8" y2="17"/> <line x1="10" y1="9" x2="8" y2="9"/> </svg> </div> <div class="theme-card-title">{{ 'generate2.theme.simple_title'|trans({}, 'whr-public') }}</div> <div class="theme-card-desc">{{ 'generate2.theme.simple_desc'|trans({}, 'whr-public') }}</div> <div class="theme-check"> <svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg> </div> </div> <div class="theme-card" data-theme="compact"> <div class="theme-card-ico"> <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"> <line x1="8" y1="6" x2="21" y2="6"/> <line x1="8" y1="12" x2="21" y2="12"/> <line x1="8" y1="18" x2="21" y2="18"/> <line x1="3" y1="6" x2="3.01" y2="6"/> <line x1="3" y1="12" x2="3.01" y2="12"/> <line x1="3" y1="18" x2="3.01" y2="18"/> </svg> </div> <div class="theme-card-title">{{ 'generate2.theme.compact_title'|trans({}, 'whr-public') }}</div> <div class="theme-card-desc">{{ 'generate2.theme.compact_desc'|trans({}, 'whr-public') }}</div> <div class="theme-check"> <svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg> </div> </div> </div> </div> <div class="fade-up d2"> <div class="section-label">{{ 'generate2.theme.section_color'|trans({}, 'whr-public') }}</div> <div class="colors-grid" id="colorsGrid"></div> </div> <div class="fade-up d3"> <div class="section-label">{{ 'generate2.theme.section_language'|trans({}, 'whr-public') }}</div> <div class="lang-grid" id="langGrid"></div> </div> <div class="cta-row fade-up d4"> <a href="{{ path('cv_public_choice', { slug: public_slug }) }}" class="btn-ghost"> <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <path d="M19 12H5M12 19l-7-7 7-7"/> </svg> {{ 'generate2.common.back'|trans({}, 'whr-public') }} </a> <button class="btn-primary" id="btnContinue" onclick="continueToForm()"> {{ 'generate2.common.continue'|trans({}, 'whr-public') }} <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <path d="M5 12h14M12 5l7 7-7 7"/> </svg> </button> </div>{% endblock %}{% block body_js %} <script> const COLORS = [ { id: 'bleu_ciel', label: 'Bleu ciel', primary: '#00AEEF', secondary: '#F59E0B' }, { id: 'charbon', label: 'Charbon', primary: '#374151', secondary: '#9CA3AF' }, { id: 'emeraude', label: 'Émeraude', primary: '#10B981', secondary: '#F59E0B' }, { id: 'navy', label: 'Navy', primary: '#1E3A5F', secondary: '#3B82F6' }, { id: 'or', label: 'Or', primary: '#B8860B', secondary: '#F59E0B' }, { id: 'rose', label: 'Rose', primary: '#EC4899', secondary: '#F9A8D4' }, { id: 'rouge', label: 'Rouge', primary: '#DC2626', secondary: '#F87171' }, { id: 'corail', label: 'Corail', primary: '#F97316', secondary: '#FED7AA' }, { id: 'vert', label: 'Vert', primary: '#16A34A', secondary: '#86EFAC' }, { id: 'bleu', label: 'Bleu', primary: '#2563EB', secondary: '#93C5FD' }, { id: 'violet', label: 'Violet', primary: '#7C3AED', secondary: '#C4B5FD' }, ]; const CV_LANGUAGES = [ { code: 'fr', label: 'Français' }, { code: 'en', label: 'English' }, { code: 'de', label: 'Deutsch' }, { code: 'es', label: 'Español' }, { code: 'it', label: 'Italiano' }, { code: 'pt', label: 'Português' }, { code: 'nl', label: 'Nederlands' }, { code: 'pl', label: 'Polski' }, { code: 'ro', label: 'Română' }, { code: 'cs', label: 'Čeština' }, { code: 'sk', label: 'Slovenčina' }, { code: 'hu', label: 'Magyar' }, { code: 'sv', label: 'Svenska' }, { code: 'da', label: 'Dansk' }, { code: 'fi', label: 'Suomi' }, { code: 'nb', label: 'Norsk' }, { code: 'el', label: 'Ελληνικά' }, { code: 'bg', label: 'Български' }, { code: 'hr', label: 'Hrvatski' }, { code: 'sl', label: 'Slovenščina' }, { code: 'lt', label: 'Lietuvių' }, { code: 'lv', label: 'Latviešu' }, { code: 'et', label: 'Eesti' }, { code: 'uk', label: 'Українська' }, { code: 'tr', label: 'Türkçe' }, ]; // État local synchronisé avec l'API via save-draft const state = { theme: 'simple', primary: COLORS[0].primary, secondary: COLORS[0].secondary, cv_language: window.__CV_CTX__.locale || 'fr', data: null, }; function renderColors() { const grid = document.getElementById('colorsGrid'); grid.innerHTML = COLORS.map(c => ` <div class="color-wrap"> <div class="color-swatch ${state.primary === c.primary ? 'selected' : ''}" style="background:${c.primary}" data-color-id="${c.id}" data-primary="${c.primary}" data-secondary="${c.secondary}" onclick="selectColor(this)"> <svg class="check" width="20" height="20" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"> <path d="M20 6L9 17l-5-5"/> </svg> </div> <span class="color-label">${c.label}</span> </div> `).join(''); } function renderLangs() { const grid = document.getElementById('langGrid'); grid.innerHTML = CV_LANGUAGES.map(l => ` <button class="lang-btn ${state.cv_language === l.code ? 'selected' : ''}" data-lang="${l.code}" onclick="selectLang('${l.code}')"> ${l.label} </button> `).join(''); } function renderTheme() { document.querySelectorAll('.theme-card').forEach(c => c.classList.toggle('selected', c.dataset.theme === state.theme)); } window.selectColor = (el) => { state.primary = el.dataset.primary; state.secondary = el.dataset.secondary; document.querySelectorAll('.color-swatch').forEach(s => s.classList.toggle('selected', s === el)); scheduleSave(); }; window.selectLang = (code) => { state.cv_language = code; document.querySelectorAll('.lang-btn').forEach(b => b.classList.toggle('selected', b.dataset.lang === code)); scheduleSave(); }; document.querySelectorAll('.theme-card').forEach(card => { card.addEventListener('click', () => { state.theme = card.dataset.theme; renderTheme(); scheduleSave(); }); }); // ═══════════════════════════════════ // CHARGEMENT INITIAL (GET /data) // ═══════════════════════════════════ async function loadExisting() { try { const res = await fetch(window.__CV_CTX__.apiBase + '/data' + window.publicQuery(), { headers: { 'Content-Type': 'application/json' } }); if (!res.ok) { console.warn('[CV] GET /data a retourné HTTP ' + res.status); return; } const ct = (res.headers.get('content-type') || '').toLowerCase(); if (!ct.includes('application/json')) { console.error('[CV] /data a renvoyé du non-JSON. Vérifiez le préfixe API (' + window.__CV_CTX__.apiBase + ').'); return; } const json = await res.json(); if (json.settings) { state.theme = json.settings.theme || state.theme; state.primary = json.settings.primary || state.primary; state.secondary = json.settings.secondary || state.secondary; state.cv_language = json.settings.cv_language || state.cv_language; } if (json.data) state.data = json.data; } catch (e) { console.error('[CV] Erreur chargement données :', e); } } // ═══════════════════════════════════ // AUTOSAVE // // Stratégie identique à form.html.twig : // - debounce 2s sur chaque sélection (thème, couleur, langue) // - flush via fetch({keepalive:true}) sur beforeunload / visibilitychange // - flush au clic "Continuer" (déjà fait dans continueToForm) // Pas de flush sur blur ici : la page theme n'a pas de champ texte, tous // les contrôles sont des clics donc déjà capturés par le debounce court. // ═══════════════════════════════════ let saveTimer = null, saveInFlight = false, dirty = false; function buildSavePayload() { return { data: state.data || { first_name: '', last_name: '', email: window.__CV_CTX__.user.email || '' }, theme: state.theme, primary: state.primary, secondary: state.secondary, cv_language: state.cv_language, }; } async function scheduleSave() { dirty = true; window.setSaveStatus('saving'); if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 2000); } async function saveNow() { if (saveInFlight) return; if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } saveInFlight = true; try { const res = await fetch(window.__CV_CTX__.apiBase + '/save-draft', { method: 'POST', headers: window.authHeaders(), body: JSON.stringify(window.publicBody(buildSavePayload())), }); if (!res.ok) throw new Error(); dirty = false; window.setSaveStatus('saved'); } catch (e) { window.setSaveStatus('error'); } finally { saveInFlight = false; } } // Flush beforeunload : survit à la fermeture de l'onglet grâce à keepalive function installBeaconFlush() { const beaconSave = () => { if (!dirty || saveInFlight) return; try { fetch(window.__CV_CTX__.apiBase + '/save-draft', { method: 'POST', headers: window.authHeaders(), body: JSON.stringify(window.publicBody(buildSavePayload())), keepalive: true, }); dirty = false; } catch (e) { /* best effort */ } }; window.addEventListener('beforeunload', beaconSave); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') beaconSave(); }); } // ═══════════════════════════════════ // CONTINUER // ═══════════════════════════════════ window.continueToForm = async () => { const btn = document.getElementById('btnContinue'); btn.disabled = true; if (saveTimer) { clearTimeout(saveTimer); await saveNow(); } const params = new URLSearchParams(window.location.search); const mode = params.get('mode') || 'scratch'; window.location.href = window.__CV_CTX__.routes.form + '?mode=' + mode; }; // ═══════════════════════════════════ // INIT // ═══════════════════════════════════ (async () => { const params = new URLSearchParams(window.location.search); const mode = params.get('mode') || 'scratch'; const i18n = window.__CV_CTX__.i18n; if (mode === 'analyze') { window.cvLoader.show(i18n.loaderAnalyze, i18n.loaderAnalyzeSub); } else { window.cvLoader.show(i18n.loaderPrefs, i18n.loaderPrefsSub); } await loadExisting(); renderTheme(); renderColors(); renderLangs(); installBeaconFlush(); window.cvLoader.hide(); })(); </script>{% endblock %}