templates/application/whileresume/website/cv-public/theme.html.twig line 1

Open in your IDE?
  1. {% extends 'application/whileresume/website/cv-public/base.html.twig' %}
  2. {% set show_loader = true %}
  3. {% block title %}{{ 'generate2.theme.page_title'|trans({}, 'whr-public') }} · WhileResume{% endblock %}
  4. {% block extra_css %}
  5.     .theme-grid {
  6.     display: grid;
  7.     grid-template-columns: 1fr 1fr;
  8.     gap: 14px;
  9.     margin-bottom: 8px;
  10.     }
  11.     .theme-card {
  12.     position: relative;
  13.     padding: 24px 22px;
  14.     border: 2px solid var(--ink-10);
  15.     border-radius: var(--r-lg);
  16.     cursor: pointer;
  17.     transition: all 0.3s var(--ease);
  18.     background: var(--white);
  19.     }
  20.     .theme-card:hover { border-color: var(--ink-20); transform: translateY(-2px); }
  21.     .theme-card.selected {
  22.     border-color: var(--violet-deep);
  23.     background: var(--violet-ultra);
  24.     box-shadow: 0 8px 28px var(--violet-glow);
  25.     }
  26.     .theme-card-ico {
  27.     width: 44px; height: 44px;
  28.     border-radius: var(--r-sm);
  29.     background: var(--violet-soft);
  30.     color: var(--violet-dark);
  31.     display: flex;
  32.     align-items: center;
  33.     justify-content: center;
  34.     margin-bottom: 14px;
  35.     transition: all 0.25s;
  36.     }
  37.     .theme-card.selected .theme-card-ico { background: var(--violet-deep); color: white; }
  38.     .theme-card-title { font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
  39.     .theme-card-desc  { font-size: 0.82rem; color: var(--ink-60); line-height: 1.5; }
  40.     .theme-check {
  41.     position: absolute;
  42.     top: 14px; right: 14px;
  43.     width: 22px; height: 22px;
  44.     border-radius: 50%;
  45.     background: var(--violet-deep);
  46.     color: white;
  47.     display: flex;
  48.     align-items: center;
  49.     justify-content: center;
  50.     opacity: 0;
  51.     transform: scale(0.5);
  52.     transition: all 0.3s var(--ease-spring);
  53.     }
  54.     .theme-card.selected .theme-check { opacity: 1; transform: scale(1); }
  55.     /* ═══════════════════════════════════
  56.     COULEURS EN CERCLES
  57.     ═══════════════════════════════════ */
  58.     .colors-grid {
  59.     display: grid;
  60.     grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
  61.     gap: 14px;
  62.     }
  63.     .color-wrap {
  64.     display: flex;
  65.     flex-direction: column;
  66.     align-items: center;
  67.     gap: 6px;
  68.     }
  69.     .color-swatch {
  70.     width: 52px;
  71.     height: 52px;
  72.     border-radius: 50%;
  73.     cursor: pointer;
  74.     position: relative;
  75.     border: 3px solid transparent;
  76.     box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  77.     transition: all 0.3s var(--ease-spring);
  78.     display: flex;
  79.     align-items: center;
  80.     justify-content: center;
  81.     }
  82.     .color-swatch:hover {
  83.     transform: scale(1.12);
  84.     box-shadow: 0 6px 16px rgba(0,0,0,0.15);
  85.     }
  86.     .color-swatch.selected {
  87.     transform: scale(1.15);
  88.     border-color: var(--white);
  89.     box-shadow: 0 0 0 3px var(--ink), 0 6px 18px rgba(0,0,0,0.2);
  90.     }
  91.     .color-swatch .check {
  92.     opacity: 0;
  93.     transform: scale(0.4);
  94.     transition: all 0.25s var(--ease-spring);
  95.     color: white;
  96.     }
  97.     .color-swatch.selected .check { opacity: 1; transform: scale(1); }
  98.     .color-label {
  99.     font-size: 0.72rem;
  100.     color: var(--ink-60);
  101.     font-weight: 500;
  102.     text-align: center;
  103.     }
  104.     .color-swatch.selected + .color-label { color: var(--ink); font-weight: 600; }
  105.     /* ═══════════════════════════════════
  106.     LANGUES EN BOUTONS PILL
  107.     ═══════════════════════════════════ */
  108.     .lang-grid {
  109.     display: flex;
  110.     flex-wrap: wrap;
  111.     gap: 8px;
  112.     }
  113.     .lang-btn {
  114.     padding: 10px 18px;
  115.     border: 1.5px solid var(--ink-10);
  116.     background: var(--white);
  117.     border-radius: var(--r-full);
  118.     font-family: var(--font-body);
  119.     font-size: 0.86rem;
  120.     font-weight: 500;
  121.     color: var(--ink-60);
  122.     cursor: pointer;
  123.     transition: all 0.2s var(--ease);
  124.     user-select: none;
  125.     }
  126.     .lang-btn:hover {
  127.     border-color: var(--ink-20);
  128.     color: var(--ink-80);
  129.     transform: translateY(-1px);
  130.     }
  131.     .lang-btn.selected {
  132.     background: var(--violet-deep);
  133.     border-color: var(--violet-deep);
  134.     color: white;
  135.     box-shadow: 0 4px 14px var(--violet-glow);
  136.     }
  137.     .lang-btn.selected:hover {
  138.     background: var(--violet-dark);
  139.     transform: translateY(-1px);
  140.     }
  141. {% endblock %}
  142. {% block body %}
  143.     <div class="fade-up">
  144.         <div class="badge"><span class="badge-num">2</span> {{ 'generate2.theme.badge'|trans({}, 'whr-public') }}</div>
  145.         <h1 class="stitle">{{ 'generate2.theme.title_part1'|trans({}, 'whr-public') }}<br><em>{{ 'generate2.theme.title_part2'|trans({}, 'whr-public') }}</em></h1>
  146.         <p class="sdesc">{{ 'generate2.theme.subtitle'|trans({}, 'whr-public') }}</p>
  147.     </div>
  148.     <div class="fade-up d1">
  149.         <div class="section-label">{{ 'generate2.theme.section_template'|trans({}, 'whr-public') }}</div>
  150.         <div class="theme-grid">
  151.             <div class="theme-card" data-theme="simple">
  152.                 <div class="theme-card-ico">
  153.                     <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">
  154.                         <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
  155.                         <polyline points="14 2 14 8 20 8"/>
  156.                         <line x1="16" y1="13" x2="8" y2="13"/>
  157.                         <line x1="16" y1="17" x2="8" y2="17"/>
  158.                         <line x1="10" y1="9" x2="8" y2="9"/>
  159.                     </svg>
  160.                 </div>
  161.                 <div class="theme-card-title">{{ 'generate2.theme.simple_title'|trans({}, 'whr-public') }}</div>
  162.                 <div class="theme-card-desc">{{ 'generate2.theme.simple_desc'|trans({}, 'whr-public') }}</div>
  163.                 <div class="theme-check">
  164.                     <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>
  165.                 </div>
  166.             </div>
  167.             <div class="theme-card" data-theme="compact">
  168.                 <div class="theme-card-ico">
  169.                     <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">
  170.                         <line x1="8" y1="6" x2="21" y2="6"/>
  171.                         <line x1="8" y1="12" x2="21" y2="12"/>
  172.                         <line x1="8" y1="18" x2="21" y2="18"/>
  173.                         <line x1="3" y1="6" x2="3.01" y2="6"/>
  174.                         <line x1="3" y1="12" x2="3.01" y2="12"/>
  175.                         <line x1="3" y1="18" x2="3.01" y2="18"/>
  176.                     </svg>
  177.                 </div>
  178.                 <div class="theme-card-title">{{ 'generate2.theme.compact_title'|trans({}, 'whr-public') }}</div>
  179.                 <div class="theme-card-desc">{{ 'generate2.theme.compact_desc'|trans({}, 'whr-public') }}</div>
  180.                 <div class="theme-check">
  181.                     <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>
  182.                 </div>
  183.             </div>
  184.         </div>
  185.     </div>
  186.     <div class="fade-up d2">
  187.         <div class="section-label">{{ 'generate2.theme.section_color'|trans({}, 'whr-public') }}</div>
  188.         <div class="colors-grid" id="colorsGrid"></div>
  189.     </div>
  190.     <div class="fade-up d3">
  191.         <div class="section-label">{{ 'generate2.theme.section_language'|trans({}, 'whr-public') }}</div>
  192.         <div class="lang-grid" id="langGrid"></div>
  193.     </div>
  194.     <div class="cta-row fade-up d4">
  195.         <a href="{{ path('cv_public_choice', { slug: public_slug }) }}" class="btn-ghost">
  196.             <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  197.                 <path d="M19 12H5M12 19l-7-7 7-7"/>
  198.             </svg>
  199.             {{ 'generate2.common.back'|trans({}, 'whr-public') }}
  200.         </a>
  201.         <button class="btn-primary" id="btnContinue" onclick="continueToForm()">
  202.             {{ 'generate2.common.continue'|trans({}, 'whr-public') }}
  203.             <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  204.                 <path d="M5 12h14M12 5l7 7-7 7"/>
  205.             </svg>
  206.         </button>
  207.     </div>
  208. {% endblock %}
  209. {% block body_js %}
  210.     <script>
  211.         const COLORS = [
  212.             { id: 'bleu_ciel', label: 'Bleu ciel',  primary: '#00AEEF', secondary: '#F59E0B' },
  213.             { id: 'charbon',   label: 'Charbon',    primary: '#374151', secondary: '#9CA3AF' },
  214.             { id: 'emeraude',  label: 'Émeraude',   primary: '#10B981', secondary: '#F59E0B' },
  215.             { id: 'navy',      label: 'Navy',       primary: '#1E3A5F', secondary: '#3B82F6' },
  216.             { id: 'or',        label: 'Or',         primary: '#B8860B', secondary: '#F59E0B' },
  217.             { id: 'rose',      label: 'Rose',       primary: '#EC4899', secondary: '#F9A8D4' },
  218.             { id: 'rouge',     label: 'Rouge',      primary: '#DC2626', secondary: '#F87171' },
  219.             { id: 'corail',    label: 'Corail',     primary: '#F97316', secondary: '#FED7AA' },
  220.             { id: 'vert',      label: 'Vert',       primary: '#16A34A', secondary: '#86EFAC' },
  221.             { id: 'bleu',      label: 'Bleu',       primary: '#2563EB', secondary: '#93C5FD' },
  222.             { id: 'violet',    label: 'Violet',     primary: '#7C3AED', secondary: '#C4B5FD' },
  223.         ];
  224.         const CV_LANGUAGES = [
  225.             { code: 'fr', label: 'Français'    }, { code: 'en', label: 'English'     },
  226.             { code: 'de', label: 'Deutsch'     }, { code: 'es', label: 'Español'     },
  227.             { code: 'it', label: 'Italiano'    }, { code: 'pt', label: 'Português'   },
  228.             { code: 'nl', label: 'Nederlands'  }, { code: 'pl', label: 'Polski'      },
  229.             { code: 'ro', label: 'Română'      }, { code: 'cs', label: 'Čeština'     },
  230.             { code: 'sk', label: 'Slovenčina'  }, { code: 'hu', label: 'Magyar'      },
  231.             { code: 'sv', label: 'Svenska'     }, { code: 'da', label: 'Dansk'       },
  232.             { code: 'fi', label: 'Suomi'       }, { code: 'nb', label: 'Norsk'       },
  233.             { code: 'el', label: 'Ελληνικά'    }, { code: 'bg', label: 'Български'   },
  234.             { code: 'hr', label: 'Hrvatski'    }, { code: 'sl', label: 'Slovenščina' },
  235.             { code: 'lt', label: 'Lietuvių'    }, { code: 'lv', label: 'Latviešu'    },
  236.             { code: 'et', label: 'Eesti'       }, { code: 'uk', label: 'Українська'  },
  237.             { code: 'tr', label: 'Türkçe'      },
  238.         ];
  239.         // État local synchronisé avec l'API via save-draft
  240.         const state = {
  241.             theme:       'simple',
  242.             primary:     COLORS[0].primary,
  243.             secondary:   COLORS[0].secondary,
  244.             cv_language: window.__CV_CTX__.locale || 'fr',
  245.             data:        null,
  246.         };
  247.         function renderColors() {
  248.             const grid = document.getElementById('colorsGrid');
  249.             grid.innerHTML = COLORS.map(c => `
  250.             <div class="color-wrap">
  251.                 <div class="color-swatch ${state.primary === c.primary ? 'selected' : ''}"
  252.                      style="background:${c.primary}"
  253.                      data-color-id="${c.id}"
  254.                      data-primary="${c.primary}"
  255.                      data-secondary="${c.secondary}"
  256.                      onclick="selectColor(this)">
  257.                     <svg class="check" width="20" height="20" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
  258.                         <path d="M20 6L9 17l-5-5"/>
  259.                     </svg>
  260.                 </div>
  261.                 <span class="color-label">${c.label}</span>
  262.             </div>
  263.         `).join('');
  264.         }
  265.         function renderLangs() {
  266.             const grid = document.getElementById('langGrid');
  267.             grid.innerHTML = CV_LANGUAGES.map(l => `
  268.             <button class="lang-btn ${state.cv_language === l.code ? 'selected' : ''}"
  269.                     data-lang="${l.code}"
  270.                     onclick="selectLang('${l.code}')">
  271.                 ${l.label}
  272.             </button>
  273.         `).join('');
  274.         }
  275.         function renderTheme() {
  276.             document.querySelectorAll('.theme-card').forEach(c =>
  277.                 c.classList.toggle('selected', c.dataset.theme === state.theme));
  278.         }
  279.         window.selectColor = (el) => {
  280.             state.primary   = el.dataset.primary;
  281.             state.secondary = el.dataset.secondary;
  282.             document.querySelectorAll('.color-swatch').forEach(s =>
  283.                 s.classList.toggle('selected', s === el));
  284.             scheduleSave();
  285.         };
  286.         window.selectLang = (code) => {
  287.             state.cv_language = code;
  288.             document.querySelectorAll('.lang-btn').forEach(b =>
  289.                 b.classList.toggle('selected', b.dataset.lang === code));
  290.             scheduleSave();
  291.         };
  292.         document.querySelectorAll('.theme-card').forEach(card => {
  293.             card.addEventListener('click', () => {
  294.                 state.theme = card.dataset.theme;
  295.                 renderTheme();
  296.                 scheduleSave();
  297.             });
  298.         });
  299.         // ═══════════════════════════════════
  300.         // CHARGEMENT INITIAL (GET /data)
  301.         // ═══════════════════════════════════
  302.         async function loadExisting() {
  303.             try {
  304.                 const res = await fetch(window.__CV_CTX__.apiBase + '/data' + window.publicQuery(), {
  305.                     headers: { 'Content-Type': 'application/json' }
  306.                 });
  307.                 if (!res.ok) {
  308.                     console.warn('[CV] GET /data a retourné HTTP ' + res.status);
  309.                     return;
  310.                 }
  311.                 const ct = (res.headers.get('content-type') || '').toLowerCase();
  312.                 if (!ct.includes('application/json')) {
  313.                     console.error('[CV] /data a renvoyé du non-JSON. Vérifiez le préfixe API (' + window.__CV_CTX__.apiBase + ').');
  314.                     return;
  315.                 }
  316.                 const json = await res.json();
  317.                 if (json.settings) {
  318.                     state.theme       = json.settings.theme       || state.theme;
  319.                     state.primary     = json.settings.primary     || state.primary;
  320.                     state.secondary   = json.settings.secondary   || state.secondary;
  321.                     state.cv_language = json.settings.cv_language || state.cv_language;
  322.                 }
  323.                 if (json.data) state.data = json.data;
  324.             } catch (e) {
  325.                 console.error('[CV] Erreur chargement données :', e);
  326.             }
  327.         }
  328.         // ═══════════════════════════════════
  329.         // AUTOSAVE
  330.         //
  331.         // Stratégie identique à form.html.twig :
  332.         //   - debounce 2s sur chaque sélection (thème, couleur, langue)
  333.         //   - flush via fetch({keepalive:true}) sur beforeunload / visibilitychange
  334.         //   - flush au clic "Continuer" (déjà fait dans continueToForm)
  335.         // Pas de flush sur blur ici : la page theme n'a pas de champ texte, tous
  336.         // les contrôles sont des clics donc déjà capturés par le debounce court.
  337.         // ═══════════════════════════════════
  338.         let saveTimer = null, saveInFlight = false, dirty = false;
  339.         function buildSavePayload() {
  340.             return {
  341.                 data:        state.data || { first_name: '', last_name: '', email: window.__CV_CTX__.user.email || '' },
  342.                 theme:       state.theme,
  343.                 primary:     state.primary,
  344.                 secondary:   state.secondary,
  345.                 cv_language: state.cv_language,
  346.             };
  347.         }
  348.         async function scheduleSave() {
  349.             dirty = true;
  350.             window.setSaveStatus('saving');
  351.             if (saveTimer) clearTimeout(saveTimer);
  352.             saveTimer = setTimeout(saveNow, 2000);
  353.         }
  354.         async function saveNow() {
  355.             if (saveInFlight) return;
  356.             if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
  357.             saveInFlight = true;
  358.             try {
  359.                 const res = await fetch(window.__CV_CTX__.apiBase + '/save-draft', {
  360.                     method: 'POST',
  361.                     headers: window.authHeaders(),
  362.                     body: JSON.stringify(window.publicBody(buildSavePayload())),
  363.                 });
  364.                 if (!res.ok) throw new Error();
  365.                 dirty = false;
  366.                 window.setSaveStatus('saved');
  367.             } catch (e) {
  368.                 window.setSaveStatus('error');
  369.             } finally {
  370.                 saveInFlight = false;
  371.             }
  372.         }
  373.         // Flush beforeunload : survit à la fermeture de l'onglet grâce à keepalive
  374.         function installBeaconFlush() {
  375.             const beaconSave = () => {
  376.                 if (!dirty || saveInFlight) return;
  377.                 try {
  378.                     fetch(window.__CV_CTX__.apiBase + '/save-draft', {
  379.                         method:    'POST',
  380.                         headers:   window.authHeaders(),
  381.                         body:      JSON.stringify(window.publicBody(buildSavePayload())),
  382.                         keepalive: true,
  383.                     });
  384.                     dirty = false;
  385.                 } catch (e) { /* best effort */ }
  386.             };
  387.             window.addEventListener('beforeunload', beaconSave);
  388.             document.addEventListener('visibilitychange', () => {
  389.                 if (document.visibilityState === 'hidden') beaconSave();
  390.             });
  391.         }
  392.         // ═══════════════════════════════════
  393.         // CONTINUER
  394.         // ═══════════════════════════════════
  395.         window.continueToForm = async () => {
  396.             const btn = document.getElementById('btnContinue');
  397.             btn.disabled = true;
  398.             if (saveTimer) { clearTimeout(saveTimer); await saveNow(); }
  399.             const params = new URLSearchParams(window.location.search);
  400.             const mode = params.get('mode') || 'scratch';
  401.             window.location.href = window.__CV_CTX__.routes.form + '?mode=' + mode;
  402.         };
  403.         // ═══════════════════════════════════
  404.         // INIT
  405.         // ═══════════════════════════════════
  406.         (async () => {
  407.             const params = new URLSearchParams(window.location.search);
  408.             const mode = params.get('mode') || 'scratch';
  409.             const i18n = window.__CV_CTX__.i18n;
  410.             if (mode === 'analyze') {
  411.                 window.cvLoader.show(i18n.loaderAnalyze, i18n.loaderAnalyzeSub);
  412.             } else {
  413.                 window.cvLoader.show(i18n.loaderPrefs, i18n.loaderPrefsSub);
  414.             }
  415.             await loadExisting();
  416.             renderTheme();
  417.             renderColors();
  418.             renderLangs();
  419.             installBeaconFlush();
  420.             window.cvLoader.hide();
  421.         })();
  422.     </script>
  423. {% endblock %}