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 %}