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

Open in your IDE?
  1. <!DOCTYPE html>
  2. <html lang="{{ app.request.locale|default('fr') }}">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>{% block title %}WhileResume{% endblock %}</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com">
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  9. <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,400;1,9..144,600&family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
  10. <style>
  11. :root {
  12. --violet: #D0BEFF;
  13. --violet-deep: #8B6FC0;
  14. --violet-dark: #6B4FA0;
  15. --violet-soft: #F0EAFF;
  16. --violet-ultra: #F8F5FF;
  17. --violet-glow: rgba(208,190,255,0.35);
  18. --ink: #1E1E2E;
  19. --ink-80: #3D3D52;
  20. --ink-60: #62627A;
  21. --ink-40: #9090A7;
  22. --ink-20: #C8C8D6;
  23. --ink-10: #E6E6ED;
  24. --ink-05: #F2F2F6;
  25. --cream: #FBFAFF;
  26. --white: #FFFFFF;
  27. --emerald: #34C77B;
  28. --emerald-soft: #EDFCF4;
  29. --ocean: #4E8AE6;
  30. --ocean-soft: #EDF3FE;
  31. --amber: #E6A23C;
  32. --amber-soft: #FEF6E8;
  33. --rose: #E8457A;
  34. --rose-soft: #FEF0F4;
  35. --danger: #EF4444;
  36. --danger-soft: #FEF2F2;
  37. --font-display: 'Fraunces', serif;
  38. --font-body: 'Outfit', sans-serif;
  39. --font-mono: 'JetBrains Mono', monospace;
  40. --r-sm: 10px;
  41. --r-md: 14px;
  42. --r-lg: 20px;
  43. --r-xl: 28px;
  44. --r-full: 100px;
  45. --ease: cubic-bezier(0.16, 1, 0.3, 1);
  46. --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  47. }
  48. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  49. html { scroll-behavior: smooth; }
  50. body {
  51. font-family: var(--font-body);
  52. background: var(--cream);
  53. color: var(--ink);
  54. -webkit-font-smoothing: antialiased;
  55. line-height: 1.6;
  56. overflow-x: hidden;
  57. min-height: 100vh;
  58. }
  59. body::after {
  60. content: '';
  61. position: fixed;
  62. inset: 0;
  63. pointer-events: none;
  64. z-index: 9999;
  65. opacity: 0.018;
  66. background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  67. background-size: 200px;
  68. }
  69. /* TOP BAR */
  70. .topbar {
  71. position: fixed;
  72. top: 0; left: 0; right: 0;
  73. z-index: 90;
  74. display: flex;
  75. align-items: center;
  76. justify-content: space-between;
  77. padding: 0 40px;
  78. height: 60px;
  79. background: rgba(251,250,255,0.88);
  80. backdrop-filter: blur(16px) saturate(1.6);
  81. border-bottom: 1px solid rgba(208,190,255,0.15);
  82. }
  83. .topbar-logo {
  84. font-family: var(--font-display);
  85. font-size: 1.3rem;
  86. font-weight: 600;
  87. text-decoration: none;
  88. color: var(--ink);
  89. letter-spacing: -0.03em;
  90. }
  91. .topbar-logo span { color: var(--violet-dark); font-style: italic; }
  92. .topbar-right { display: flex; align-items: center; gap: 16px; }
  93. .topbar-save {
  94. display: none;
  95. align-items: center;
  96. gap: 6px;
  97. font-size: 0.75rem;
  98. color: var(--ink-60);
  99. }
  100. .topbar-save.visible { display: flex; }
  101. .topbar-save.saved { color: var(--emerald); }
  102. .topbar-save.saving { color: var(--ink-60); }
  103. .topbar-save.error { color: var(--danger); }
  104. .topbar-save-dot {
  105. width: 6px; height: 6px; border-radius: 50%;
  106. background: currentColor;
  107. }
  108. .topbar-save.saving .topbar-save-dot { animation: pulse 1.2s ease-in-out infinite; }
  109. .topbar-link {
  110. font-size: 0.82rem;
  111. font-weight: 500;
  112. color: var(--ink-60);
  113. text-decoration: none;
  114. transition: color 0.2s;
  115. }
  116. .topbar-link:hover { color: var(--violet-dark); }
  117. /* Bouton violet "Retour au dashboard" dans la topbar */
  118. .topbar-btn-primary {
  119. display: inline-flex;
  120. align-items: center;
  121. gap: 8px;
  122. padding: 9px 18px;
  123. background: linear-gradient(135deg, var(--violet-deep), var(--violet-dark));
  124. color: white;
  125. font-family: var(--font-body);
  126. font-size: 0.82rem;
  127. font-weight: 600;
  128. text-decoration: none;
  129. border-radius: var(--r-full);
  130. box-shadow: 0 4px 14px var(--violet-glow);
  131. transition: all 0.25s var(--ease);
  132. white-space: nowrap;
  133. }
  134. .topbar-btn-primary:hover {
  135. transform: translateY(-1px);
  136. box-shadow: 0 8px 22px rgba(139, 111, 192, 0.35);
  137. background: linear-gradient(135deg, var(--violet-dark), var(--violet-deep));
  138. }
  139. .topbar-btn-primary:active {
  140. transform: translateY(0);
  141. box-shadow: 0 3px 10px var(--violet-glow);
  142. }
  143. .topbar-btn-primary svg {
  144. flex-shrink: 0;
  145. }
  146. .topbar-avatar {
  147. width: 34px;
  148. height: 34px;
  149. border-radius: 50%;
  150. background: linear-gradient(135deg, var(--violet), var(--violet-deep));
  151. color: white;
  152. display: flex;
  153. align-items: center;
  154. justify-content: center;
  155. font-weight: 700;
  156. font-size: 0.72rem;
  157. }
  158. /* PAGE CONTAINER */
  159. .page-container {
  160. min-height: 100vh;
  161. padding: 110px 100px 60px;
  162. position: relative;
  163. }
  164. .page-inner {
  165. max-width: 820px;
  166. margin: 0 auto;
  167. position: relative;
  168. }
  169. /* DECO BLOBS */
  170. .page-container::before,
  171. .page-container::after {
  172. content: '';
  173. position: fixed;
  174. border-radius: 50%;
  175. pointer-events: none;
  176. opacity: 0.4;
  177. z-index: 0;
  178. }
  179. .page-container::before {
  180. width: 520px; height: 520px;
  181. top: -120px; right: -160px;
  182. background: radial-gradient(circle, var(--violet-soft), transparent 70%);
  183. }
  184. .page-container::after {
  185. width: 400px; height: 400px;
  186. bottom: -80px; left: -120px;
  187. background: radial-gradient(circle, var(--ocean-soft), transparent 70%);
  188. }
  189. /* STEPS PROGRESS (top of page under topbar) */
  190. .tunnel-progress {
  191. position: fixed;
  192. top: 60px;
  193. left: 0; right: 0;
  194. z-index: 80;
  195. background: rgba(251,250,255,0.85);
  196. backdrop-filter: blur(12px);
  197. border-bottom: 1px solid rgba(208,190,255,0.1);
  198. padding: 16px 40px;
  199. }
  200. .tunnel-progress-inner {
  201. max-width: 820px;
  202. margin: 0 auto;
  203. display: flex;
  204. align-items: center;
  205. gap: 16px;
  206. }
  207. .tp-step {
  208. display: flex;
  209. align-items: center;
  210. gap: 10px;
  211. flex: 1;
  212. opacity: 0.4;
  213. transition: opacity 0.3s;
  214. }
  215. .tp-step.active, .tp-step.done { opacity: 1; }
  216. .tp-dot {
  217. width: 26px; height: 26px;
  218. border-radius: 50%;
  219. background: var(--ink-10);
  220. color: var(--ink-40);
  221. display: flex;
  222. align-items: center;
  223. justify-content: center;
  224. font-size: 0.72rem;
  225. font-weight: 700;
  226. flex-shrink: 0;
  227. transition: all 0.3s var(--ease-spring);
  228. }
  229. .tp-step.active .tp-dot {
  230. background: var(--violet-deep);
  231. color: white;
  232. box-shadow: 0 0 0 4px var(--violet-glow);
  233. }
  234. .tp-step.done .tp-dot {
  235. background: var(--emerald);
  236. color: white;
  237. }
  238. .tp-label {
  239. font-size: 0.78rem;
  240. font-weight: 500;
  241. color: var(--ink-60);
  242. white-space: nowrap;
  243. }
  244. .tp-step.active .tp-label { color: var(--violet-dark); font-weight: 600; }
  245. .tp-step.done .tp-label { color: var(--emerald); }
  246. .tp-line {
  247. flex: 1;
  248. height: 2px;
  249. background: var(--ink-10);
  250. position: relative;
  251. overflow: hidden;
  252. min-width: 20px;
  253. }
  254. .tp-line::after {
  255. content: '';
  256. position: absolute;
  257. top: 0; left: 0;
  258. width: 100%;
  259. height: 100%;
  260. background: var(--emerald);
  261. transform: scaleX(0);
  262. transform-origin: left;
  263. transition: transform 0.5s var(--ease);
  264. }
  265. .tp-line.done::after { transform: scaleX(1); }
  266. .page-container { padding-top: 150px; }
  267. /* BADGE / TITLES */
  268. .badge {
  269. display: inline-flex;
  270. align-items: center;
  271. gap: 8px;
  272. padding: 6px 16px;
  273. border-radius: var(--r-full);
  274. font-size: 0.7rem;
  275. font-weight: 600;
  276. text-transform: uppercase;
  277. letter-spacing: 0.1em;
  278. margin-bottom: 24px;
  279. background: var(--violet-soft);
  280. color: var(--violet-dark);
  281. }
  282. .badge-num {
  283. width: 20px; height: 20px;
  284. border-radius: 50%;
  285. display: flex;
  286. align-items: center;
  287. justify-content: center;
  288. font-size: 0.65rem;
  289. font-weight: 700;
  290. background: var(--violet-deep);
  291. color: white;
  292. }
  293. .stitle {
  294. font-family: var(--font-display);
  295. font-size: clamp(2rem, 4vw, 3rem);
  296. font-weight: 400;
  297. line-height: 1.15;
  298. letter-spacing: -0.03em;
  299. margin-bottom: 14px;
  300. }
  301. .stitle em { font-style: italic; color: var(--violet-dark); }
  302. .sdesc {
  303. font-size: 1.05rem;
  304. color: var(--ink-60);
  305. max-width: 540px;
  306. margin-bottom: 40px;
  307. font-weight: 300;
  308. }
  309. /* FORM ELEMENTS (communs) */
  310. .fg { display: grid; gap: 16px; }
  311. .fg.c2 { grid-template-columns: 1fr 1fr; }
  312. .fg.c3 { grid-template-columns: 1fr 1fr 1fr; }
  313. .flabel {
  314. display: block;
  315. font-size: 0.72rem;
  316. font-weight: 600;
  317. text-transform: uppercase;
  318. letter-spacing: 0.08em;
  319. color: var(--ink-40);
  320. margin-bottom: 8px;
  321. }
  322. .flabel .req { color: var(--rose); margin-left: 2px; }
  323. .flabel .opt { opacity: 0.5; text-transform: none; letter-spacing: 0; }
  324. .finput, .ftextarea, .fselect {
  325. width: 100%;
  326. padding: 13px 16px;
  327. border: 1.5px solid var(--ink-10);
  328. border-radius: var(--r-md);
  329. font-family: var(--font-body);
  330. font-size: 0.92rem;
  331. color: var(--ink);
  332. background: var(--white);
  333. outline: none;
  334. transition: border-color 0.25s, box-shadow 0.25s;
  335. }
  336. .finput:focus, .ftextarea:focus, .fselect:focus {
  337. border-color: var(--violet);
  338. box-shadow: 0 0 0 4px var(--violet-glow);
  339. }
  340. .finput.invalid, .tag-input-wrap.invalid { border-color: var(--danger); background: var(--danger-soft); }
  341. .finput::placeholder, .ftextarea::placeholder { color: var(--ink-20); }
  342. .ftextarea {
  343. resize: vertical;
  344. min-height: 130px;
  345. line-height: 1.7;
  346. }
  347. .fselect {
  348. appearance: none;
  349. -webkit-appearance: none;
  350. background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239090A7' stroke-width='2'><path d='M6 9l6 6 6-6'/></svg>");
  351. background-repeat: no-repeat;
  352. background-position: right 14px center;
  353. background-size: 16px;
  354. padding-right: 40px;
  355. cursor: pointer;
  356. }
  357. .field { margin-bottom: 14px; }
  358. .field:last-child { margin-bottom: 0; }
  359. .section-label {
  360. font-size: 0.72rem;
  361. font-weight: 600;
  362. text-transform: uppercase;
  363. letter-spacing: 0.08em;
  364. color: var(--ink-40);
  365. margin: 32px 0 14px;
  366. }
  367. .section-label:first-of-type { margin-top: 0; }
  368. /* BUTTONS */
  369. .btn-primary, .btn-secondary, .btn-ghost {
  370. display: inline-flex;
  371. align-items: center;
  372. justify-content: center;
  373. gap: 10px;
  374. padding: 15px 32px;
  375. border: none;
  376. border-radius: var(--r-full);
  377. font-family: var(--font-body);
  378. font-size: 0.95rem;
  379. font-weight: 600;
  380. cursor: pointer;
  381. transition: all 0.3s var(--ease);
  382. text-decoration: none;
  383. }
  384. .btn-primary {
  385. background: linear-gradient(135deg, var(--violet), var(--violet-deep));
  386. color: white;
  387. box-shadow: 0 6px 24px var(--violet-glow);
  388. }
  389. .btn-primary:hover {
  390. transform: translateY(-2px);
  391. box-shadow: 0 10px 32px rgba(208,190,255,0.5);
  392. }
  393. .btn-primary:disabled {
  394. opacity: 0.5;
  395. cursor: not-allowed;
  396. transform: none;
  397. }
  398. .btn-secondary {
  399. background: var(--white);
  400. color: var(--ink-80);
  401. border: 1.5px solid var(--ink-10);
  402. }
  403. .btn-secondary:hover { border-color: var(--ink-20); background: var(--ink-05); }
  404. .btn-ghost {
  405. background: transparent;
  406. color: var(--ink-60);
  407. padding: 10px 18px;
  408. }
  409. .btn-ghost:hover { color: var(--ink); background: var(--ink-05); }
  410. .cta-row {
  411. display: flex;
  412. gap: 12px;
  413. margin-top: 36px;
  414. flex-wrap: wrap;
  415. }
  416. @keyframes pulse {
  417. 0%, 100% { opacity: .6; transform: scale(.9); }
  418. 50% { opacity: 1; transform: scale(1.1); }
  419. }
  420. @keyframes fadeUp {
  421. from { opacity: 0; transform: translateY(20px); }
  422. to { opacity: 1; transform: translateY(0); }
  423. }
  424. .fade-up { animation: fadeUp 0.6s var(--ease) both; }
  425. .fade-up.d1 { animation-delay: 0.08s; }
  426. .fade-up.d2 { animation-delay: 0.16s; }
  427. .fade-up.d3 { animation-delay: 0.24s; }
  428. .fade-up.d4 { animation-delay: 0.32s; }
  429. /* ═══════════════════════════════════
  430. LOADER OVERLAY
  431. ═══════════════════════════════════ */
  432. .loader-overlay {
  433. position: fixed;
  434. inset: 0;
  435. z-index: 9998;
  436. background: rgba(251,250,255,0.92);
  437. backdrop-filter: blur(8px);
  438. display: flex;
  439. flex-direction: column;
  440. align-items: center;
  441. justify-content: center;
  442. gap: 24px;
  443. opacity: 1;
  444. transition: opacity 0.4s var(--ease);
  445. pointer-events: all;
  446. }
  447. .loader-overlay.hidden {
  448. opacity: 0;
  449. pointer-events: none;
  450. }
  451. .loader-orb {
  452. width: 84px;
  453. height: 84px;
  454. position: relative;
  455. }
  456. .loader-ring {
  457. position: absolute;
  458. inset: 0;
  459. border-radius: 50%;
  460. border: 2.5px solid var(--violet-soft);
  461. }
  462. .loader-ring::after {
  463. content: '';
  464. position: absolute;
  465. inset: -2.5px;
  466. border-radius: 50%;
  467. border: 3px solid transparent;
  468. border-top-color: var(--violet-deep);
  469. animation: spin 1.2s linear infinite;
  470. }
  471. .loader-ring:nth-child(2) { inset: 14px; }
  472. .loader-ring:nth-child(2)::after {
  473. border-top-color: var(--violet);
  474. animation-duration: 1.8s;
  475. animation-direction: reverse;
  476. }
  477. .loader-core {
  478. position: absolute;
  479. inset: 26px;
  480. border-radius: 50%;
  481. background: var(--violet-soft);
  482. display: flex;
  483. align-items: center;
  484. justify-content: center;
  485. }
  486. .loader-core svg {
  487. color: var(--violet-deep);
  488. animation: pulse 2.2s ease-in-out infinite;
  489. }
  490. .loader-text {
  491. font-family: var(--font-display);
  492. font-size: 1.05rem;
  493. font-weight: 500;
  494. color: var(--ink-80);
  495. letter-spacing: -0.01em;
  496. }
  497. .loader-sub {
  498. font-size: 0.82rem;
  499. color: var(--ink-40);
  500. font-weight: 400;
  501. max-width: 280px;
  502. text-align: center;
  503. margin-top: -16px;
  504. }
  505. @keyframes spin { to { transform: rotate(360deg); } }
  506. @media (max-width: 760px) {
  507. .page-container { padding: 150px 20px 40px; }
  508. .topbar { padding: 0 20px; }
  509. .tunnel-progress { padding: 14px 20px; }
  510. .tp-label { display: none; }
  511. .fg.c2, .fg.c3 { grid-template-columns: 1fr; }
  512. }
  513. {% block extra_css %}{% endblock %}
  514. </style>
  515. </head>
  516. <body>
  517. <div class="loader-overlay{% if not show_loader|default(false) %} hidden{% endif %}" id="loaderOverlay">
  518. <div class="loader-orb">
  519. <div class="loader-ring"></div>
  520. <div class="loader-ring"></div>
  521. <div class="loader-core">
  522. <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
  523. <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
  524. <polyline points="14 2 14 8 20 8"/>
  525. </svg>
  526. </div>
  527. </div>
  528. <div class="loader-text" id="loaderText">{{ 'generate2.loader.default_title'|trans({}, 'whr-public') }}</div>
  529. <div class="loader-sub" id="loaderSub">{{ 'generate2.loader.default_sub'|trans({}, 'whr-public') }}</div>
  530. </div>
  531. <header class="topbar">
  532. {# Mode public : logo cliquable vers la homepage WhileResume (pas de dashboard puisque pas de compte) #}
  533. <a href="{{ path('locale_whileresume_homepage') }}">
  534. <img src="/uploads/logo.png" style="margin-top:10px;" alt="WhileResume" />
  535. </a>
  536. <div class="topbar-right">
  537. <div class="topbar-save" id="saveIndicator">
  538. <div class="topbar-save-dot"></div>
  539. <span id="saveText">{{ 'generate2.topbar.saved'|trans({}, 'whr-public') }}</span>
  540. </div>
  541. {# Mode public : pas de lien dashboard ni d'avatar — visiteur anonyme #}
  542. {# On garde un placeholder discret indiquant le temps restant pour rappeler les 48h #}
  543. {% if draft is defined and draft and draft.expiresAt %}
  544. <span class="topbar-link" id="cvpub-expires" data-expires-at="{{ draft.expiresAt|date('c') }}" style="font-size:0.78rem;color:var(--ink-40)">
  545. {# Le JS calculera le temps restant et l'affichera ici #}
  546. </span>
  547. {% endif %}
  548. <a href="{% if app.request.locale == "fr" %}{{ path('locale_whileresume_homepage',{'_locale':app.request.locale}) }}{% else %}{{ path('whileresume_homepage') }}{% endif %}" class="topbar-btn-primary">
  549. <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24">
  550. <path d="M3 12l9-9 9 9M5 10v10a1 1 0 001 1h4v-6h4v6h4a1 1 0 001-1V10"/>
  551. </svg>
  552. {{ 'generate2.topbar.backhomepage'|trans({}, 'whr-candidates') }}
  553. </a>
  554. </div>
  555. </header>
  556. {% block progress %}
  557. <div class="tunnel-progress">
  558. <div class="tunnel-progress-inner">
  559. <div class="tp-step {{ current_step == 1 ? 'active' : (current_step > 1 ? 'done' : '') }}">
  560. <div class="tp-dot">{{ current_step > 1 ? '✓' : '1' }}</div>
  561. <span class="tp-label">{{ 'generate2.progress.step_choice'|trans({}, 'whr-public') }}</span>
  562. </div>
  563. <div class="tp-line {{ current_step > 1 ? 'done' : '' }}"></div>
  564. <div class="tp-step {{ current_step == 2 ? 'active' : (current_step > 2 ? 'done' : '') }}">
  565. <div class="tp-dot">{{ current_step > 2 ? '✓' : '2' }}</div>
  566. <span class="tp-label">{{ 'generate2.progress.step_theme'|trans({}, 'whr-public') }}</span>
  567. </div>
  568. <div class="tp-line {{ current_step > 2 ? 'done' : '' }}"></div>
  569. <div class="tp-step {{ current_step == 3 ? 'active' : (current_step > 3 ? 'done' : '') }}">
  570. <div class="tp-dot">{{ current_step > 3 ? '✓' : '3' }}</div>
  571. <span class="tp-label">{{ 'generate2.progress.step_info'|trans({}, 'whr-public') }}</span>
  572. </div>
  573. </div>
  574. </div>
  575. {% endblock %}
  576. <main class="page-container">
  577. <div class="page-inner">
  578. {% block body %}{% endblock %}
  579. </div>
  580. </main>
  581. {# ════════════════════════════════════════════════════════════════════════════
  582. CONTEXT POUR LE JS (data-attributes)
  583. ════════════════════════════════════════════════════════════════════════════
  584. Approche imperméable aux caractères de contrôle / encodage cassé :
  585. chaque trad est un attribut HTML séparé, escapé par Twig via |e('html_attr').
  586. Si UNE trad est corrompue, ça affecte SEULEMENT cet attribut, pas le reste.
  587. #}
  588. {# Détecte si la requête courante utilise une route avec préfixe locale.
  589. Si oui, on génère TOUTES les URLs des routes du tunnel avec préfixe pour
  590. que la navigation conserve /fr/, /de/, etc. dans l'URL. #}
  591. {% set _current_route = app.request.attributes.get('_route') %}
  592. {% set _has_locale_prefix = _current_route starts with 'locale_' %}
  593. <div id="__cv_ctx_root__" style="display:none"
  594. data-api-base="{{ api_base|default('/api/cv-public')|e('html_attr') }}"
  595. data-jwt="{{ jwt|default('')|e('html_attr') }}"
  596. data-locale="{{ app.request.locale|default('fr')|e('html_attr') }}"
  597. data-is-public="{{ is_public|default(true) ? '1' : '0' }}"
  598. data-public-slug="{{ public_slug|default('')|e('html_attr') }}"
  599. data-user-first-name="{{ (user_data.first_name|default(''))|e('html_attr') }}"
  600. data-user-last-name="{{ (user_data.last_name|default(''))|e('html_attr') }}"
  601. data-user-email="{{ (user_data.email|default(''))|e('html_attr') }}"
  602. data-route-choice="{{ public_slug|default('') ? (_has_locale_prefix ? path('locale_cv_public_choice', { slug: public_slug, _locale: app.request.locale }) : path('cv_public_choice', { slug: public_slug }))|e('html_attr') : '' }}"
  603. data-route-theme="{{ public_slug|default('') ? (_has_locale_prefix ? path('locale_cv_public_theme', { slug: public_slug, _locale: app.request.locale }) : path('cv_public_theme', { slug: public_slug }))|e('html_attr') : '' }}"
  604. data-route-form="{{ public_slug|default('') ? (_has_locale_prefix ? path('locale_cv_public_form', { slug: public_slug, _locale: app.request.locale }) : path('cv_public_form', { slug: public_slug }))|e('html_attr') : '' }}"
  605. data-route-register="{{ public_slug|default('') ? (_has_locale_prefix ? path('locale_cv_public_register', { slug: public_slug, _locale: app.request.locale }) : path('cv_public_register', { slug: public_slug }))|e('html_attr') : '' }}"
  606. data-route-generate="{{ public_slug|default('') ? (_has_locale_prefix ? path('locale_cv_public_generate', { slug: public_slug, _locale: app.request.locale }) : path('cv_public_generate', { slug: public_slug }))|e('html_attr') : '' }}"
  607. data-i18n-saving="{{ 'generate2.topbar.saving'|trans({}, 'whr-public')|e('html_attr') }}"
  608. data-i18n-saved="{{ 'generate2.topbar.saved'|trans({}, 'whr-public')|e('html_attr') }}"
  609. data-i18n-save-error="{{ 'generate2.topbar.save_error'|trans({}, 'whr-public')|e('html_attr') }}"
  610. data-i18n-load-error="{{ 'generate2.form.error.load'|trans({}, 'whr-public')|e('html_attr') }}"
  611. data-i18n-analyze-error="{{ 'generate2.form.analyzing.error'|trans({}, 'whr-public')|e('html_attr') }}"
  612. data-i18n-generate-error="{{ 'generate2.form.error.generate'|trans({}, 'whr-public')|e('html_attr') }}"
  613. data-i18n-missing-fields="{{ 'generate2.form.error.missing_fields'|trans({}, 'whr-public')|e('html_attr') }}"
  614. data-i18n-generate-first="{{ 'generate2.form.error.generate_first'|trans({}, 'whr-public')|e('html_attr') }}"
  615. data-i18n-preview-loading="{{ 'generate2.done.preview_loading'|trans({}, 'whr-public')|e('html_attr') }}"
  616. data-i18n-preview-unavailable="{{ 'generate2.done.preview_unavailable'|trans({}, 'whr-public')|e('html_attr') }}"
  617. data-i18n-preview-fallback="{{ 'generate2.done.preview_fallback'|trans({}, 'whr-public')|e('html_attr') }}"
  618. data-i18n-download-error="{{ 'generate2.form.error.download'|trans({}, 'whr-public')|e('html_attr') }}"
  619. data-i18n-file-too-large="{{ 'generate2.form.upload.error_too_large'|trans({}, 'whr-public')|e('html_attr') }}"
  620. data-i18n-file-must-be-pdf="{{ 'generate2.form.upload.error_must_be_pdf'|trans({}, 'whr-public')|e('html_attr') }}"
  621. data-i18n-generating="{{ 'generate2.loader.generate_title'|trans({}, 'whr-public')|e('html_attr') }}"
  622. data-i18n-generating-hint="{{ 'generate2.loader.generate_sub'|trans({}, 'whr-public')|e('html_attr') }}"
  623. data-i18n-generate-cta="{{ 'generate2.form.cta_generate'|trans({}, 'whr-public')|e('html_attr') }}"
  624. data-i18n-generating-cta="{{ 'generate2.form.cta_generating'|trans({}, 'whr-public')|e('html_attr') }}"
  625. data-i18n-loader-analyze="{{ 'generate2.loader.analyze_done_title'|trans({}, 'whr-public')|e('html_attr') }}"
  626. data-i18n-loader-analyze-sub="{{ 'generate2.loader.analyze_done_sub'|trans({}, 'whr-public')|e('html_attr') }}"
  627. data-i18n-loader-prefs="{{ 'generate2.loader.prefs_title'|trans({}, 'whr-public')|e('html_attr') }}"
  628. data-i18n-loader-prefs-sub="{{ 'generate2.loader.prefs_sub'|trans({}, 'whr-public')|e('html_attr') }}"
  629. data-i18n-loader-form-scratch="{{ 'generate2.loader.form_scratch_title'|trans({}, 'whr-public')|e('html_attr') }}"
  630. data-i18n-loader-form-scratch-sub="{{ 'generate2.loader.form_scratch_sub'|trans({}, 'whr-public')|e('html_attr') }}"
  631. data-i18n-loader-form-edit="{{ 'generate2.loader.form_edit_title'|trans({}, 'whr-public')|e('html_attr') }}"
  632. data-i18n-loader-form-edit-sub="{{ 'generate2.loader.form_edit_sub'|trans({}, 'whr-public')|e('html_attr') }}"
  633. data-i18n-loader-form-analyze="{{ 'generate2.loader.form_analyze_title'|trans({}, 'whr-public')|e('html_attr') }}"
  634. data-i18n-loader-form-analyze-sub="{{ 'generate2.loader.form_analyze_sub'|trans({}, 'whr-public')|e('html_attr') }}"
  635. data-i18n-network-error="{{ 'generate2.form.error.network'|trans({}, 'whr-public')|e('html_attr') }}"
  636. data-i18n-title-language="{{ 'generate2.form.item_title.language'|trans({}, 'whr-public')|e('html_attr') }}"
  637. data-i18n-title-experience="{{ 'generate2.form.item_title.experience'|trans({}, 'whr-public')|e('html_attr') }}"
  638. data-i18n-title-degree="{{ 'generate2.form.item_title.degree'|trans({}, 'whr-public')|e('html_attr') }}"
  639. data-i18n-title-certification="{{ 'generate2.form.item_title.certification'|trans({}, 'whr-public')|e('html_attr') }}"
  640. data-i18n-title-project="{{ 'generate2.form.item_title.project'|trans({}, 'whr-public')|e('html_attr') }}"
  641. data-i18n-label-language="{{ 'generate2.form.label.language'|trans({}, 'whr-public')|e('html_attr') }}"
  642. data-i18n-label-level="{{ 'generate2.form.label.level'|trans({}, 'whr-public')|e('html_attr') }}"
  643. data-i18n-label-position="{{ 'generate2.form.label.position'|trans({}, 'whr-public')|e('html_attr') }}"
  644. data-i18n-label-company="{{ 'generate2.form.label.company'|trans({}, 'whr-public')|e('html_attr') }}"
  645. data-i18n-label-start-date="{{ 'generate2.form.label.start_date'|trans({}, 'whr-public')|e('html_attr') }}"
  646. data-i18n-label-end-date="{{ 'generate2.form.label.end_date'|trans({}, 'whr-public')|e('html_attr') }}"
  647. data-i18n-label-city="{{ 'generate2.form.label.city'|trans({}, 'whr-public')|e('html_attr') }}"
  648. data-i18n-label-contract-type="{{ 'generate2.form.label.contract_type'|trans({}, 'whr-public')|e('html_attr') }}"
  649. data-i18n-label-description="{{ 'generate2.form.label.description'|trans({}, 'whr-public')|e('html_attr') }}"
  650. data-i18n-label-degree="{{ 'generate2.form.label.degree'|trans({}, 'whr-public')|e('html_attr') }}"
  651. data-i18n-label-school="{{ 'generate2.form.label.school'|trans({}, 'whr-public')|e('html_attr') }}"
  652. data-i18n-label-field="{{ 'generate2.form.label.field'|trans({}, 'whr-public')|e('html_attr') }}"
  653. data-i18n-label-cert-name="{{ 'generate2.form.label.cert_name'|trans({}, 'whr-public')|e('html_attr') }}"
  654. data-i18n-label-issuer="{{ 'generate2.form.label.issuer'|trans({}, 'whr-public')|e('html_attr') }}"
  655. data-i18n-label-year="{{ 'generate2.form.label.year'|trans({}, 'whr-public')|e('html_attr') }}"
  656. data-i18n-label-project-name="{{ 'generate2.form.label.project_name'|trans({}, 'whr-public')|e('html_attr') }}"
  657. data-i18n-ph-lang-name="{{ 'generate2.form.placeholder.lang_name'|trans({}, 'whr-public')|e('html_attr') }}"
  658. data-i18n-ph-lang-level="{{ 'generate2.form.placeholder.lang_level'|trans({}, 'whr-public')|e('html_attr') }}"
  659. data-i18n-ph-position="{{ 'generate2.form.placeholder.position'|trans({}, 'whr-public')|e('html_attr') }}"
  660. data-i18n-ph-company="{{ 'generate2.form.placeholder.company'|trans({}, 'whr-public')|e('html_attr') }}"
  661. data-i18n-ph-present="{{ 'generate2.form.placeholder.end_date_present'|trans({}, 'whr-public')|e('html_attr') }}"
  662. data-i18n-ph-choose="{{ 'generate2.common.choose'|trans({}, 'whr-public')|e('html_attr') }}"
  663. data-i18n-ph-exp-desc="{{ 'generate2.form.placeholder.exp_desc'|trans({}, 'whr-public')|e('html_attr') }}"
  664. data-i18n-ph-degree="{{ 'generate2.form.placeholder.degree'|trans({}, 'whr-public')|e('html_attr') }}"
  665. data-i18n-ph-school="{{ 'generate2.form.placeholder.school'|trans({}, 'whr-public')|e('html_attr') }}"
  666. data-i18n-ph-field="{{ 'generate2.form.placeholder.field'|trans({}, 'whr-public')|e('html_attr') }}"
  667. data-i18n-ph-cert-name="{{ 'generate2.form.placeholder.cert_name'|trans({}, 'whr-public')|e('html_attr') }}"
  668. data-i18n-ph-issuer="{{ 'generate2.form.placeholder.issuer'|trans({}, 'whr-public')|e('html_attr') }}"
  669. data-i18n-ph-project-name="{{ 'generate2.form.placeholder.project_name'|trans({}, 'whr-public')|e('html_attr') }}"
  670. data-i18n-ph-project-desc="{{ 'generate2.form.placeholder.project_desc'|trans({}, 'whr-public')|e('html_attr') }}"
  671. ></div>
  672. <script>
  673. /**
  674. * Construction de window.__CV_CTX__ depuis les data-attributes du div root.
  675. * Approche imperméable aux caractères qui cassent : pas de json_encode,
  676. * pas de JSON inline, juste des attributs HTML standards.
  677. */
  678. (function () {
  679. const FALLBACK = {
  680. apiBase: '/api/cv-public',
  681. jwt: null,
  682. locale: 'fr',
  683. user: { first_name: '', last_name: '', email: '' },
  684. isPublic: true,
  685. publicSlug: '',
  686. routes: { choice: '', theme: '', form: '', register: '', generate: '' },
  687. i18n: {}
  688. };
  689. const root = document.getElementById('__cv_ctx_root__');
  690. if (!root) {
  691. console.error('[CV] __cv_ctx_root__ introuvable, fallback utilisé');
  692. window.__CV_CTX__ = FALLBACK;
  693. return;
  694. }
  695. const ds = root.dataset;
  696. // Construction de l'objet i18n depuis tous les data-i18n-* présents
  697. // Le dataset HTML5 fait déjà la conversion data-i18n-save-error -> i18nSaveError
  698. const i18n = {};
  699. Object.keys(ds).forEach(key => {
  700. if (!key.startsWith('i18n')) return;
  701. const trKey = key.charAt(4).toLowerCase() + key.slice(5);
  702. i18n[trKey] = ds[key] || '';
  703. });
  704. window.__CV_CTX__ = {
  705. apiBase: ds.apiBase || FALLBACK.apiBase,
  706. jwt: ds.jwt || null,
  707. locale: ds.locale || FALLBACK.locale,
  708. isPublic: ds.isPublic === '1',
  709. publicSlug: ds.publicSlug || '',
  710. user: {
  711. first_name: ds.userFirstName || '',
  712. last_name: ds.userLastName || '',
  713. email: ds.userEmail || ''
  714. },
  715. routes: {
  716. choice: ds.routeChoice || '',
  717. theme: ds.routeTheme || '',
  718. form: ds.routeForm || '',
  719. register: ds.routeRegister || '',
  720. generate: ds.routeGenerate || ''
  721. },
  722. i18n: i18n
  723. };
  724. })();
  725. /**
  726. * MODE PUBLIC : pas de JWT à envoyer.
  727. */
  728. window.authHeaders = function () {
  729. return { 'Content-Type': 'application/json' };
  730. };
  731. /**
  732. * Ajoute le slug au body d'une requête API publique.
  733. */
  734. window.publicBody = function (payload) {
  735. return Object.assign({}, payload || {}, {
  736. slug: window.__CV_CTX__.publicSlug
  737. });
  738. };
  739. /**
  740. * Ajoute le slug en query string pour les GET.
  741. */
  742. window.publicQuery = function () {
  743. const slug = window.__CV_CTX__.publicSlug;
  744. return slug ? '?slug=' + encodeURIComponent(slug) : '';
  745. };
  746. window.setSaveStatus = function (status) {
  747. const el = document.getElementById('saveIndicator');
  748. const txt = document.getElementById('saveText');
  749. if (!el) return;
  750. el.classList.remove('saving', 'saved', 'error');
  751. el.classList.add(status, 'visible');
  752. if (txt) {
  753. const i18n = window.__CV_CTX__.i18n || {};
  754. txt.textContent = status === 'saving' ? (i18n.saving || 'Enregistrement…')
  755. : status === 'saved' ? (i18n.saved || 'Enregistré')
  756. : (i18n.saveError || 'Erreur');
  757. }
  758. };
  759. window.cvLoader = {
  760. show(text, sub) {
  761. const o = document.getElementById('loaderOverlay');
  762. if (!o) return;
  763. const t = document.getElementById('loaderText');
  764. const s = document.getElementById('loaderSub');
  765. if (t && text) t.textContent = text;
  766. if (s && sub !== undefined) s.textContent = sub || '';
  767. o.classList.remove('hidden');
  768. },
  769. hide() {
  770. const o = document.getElementById('loaderOverlay');
  771. if (o) o.classList.add('hidden');
  772. }
  773. };
  774. /**
  775. * Filet de sécurité : si le loader reste visible 5s, fermeture forcée.
  776. */
  777. window.addEventListener('load', () => {
  778. setTimeout(() => {
  779. const o = document.getElementById('loaderOverlay');
  780. if (o && !o.classList.contains('hidden')) {
  781. console.warn('[CV] Loader bloqué après 5s, fermeture forcée');
  782. o.classList.add('hidden');
  783. }
  784. }, 5000);
  785. });
  786. /**
  787. * Countdown 48h dans la topbar.
  788. */
  789. (function () {
  790. const el = document.getElementById('cvpub-expires');
  791. if (!el || !el.dataset.expiresAt) return;
  792. const expiresAt = new Date(el.dataset.expiresAt);
  793. function update() {
  794. const now = new Date();
  795. const ms = expiresAt - now;
  796. if (ms <= 0) {
  797. el.textContent = 'Brouillon expiré';
  798. el.style.color = 'var(--danger)';
  799. return;
  800. }
  801. const h = Math.floor(ms / 3600000);
  802. const m = Math.floor((ms % 3600000) / 60000);
  803. el.textContent = h >= 1
  804. ? 'Expire dans ' + h + 'h' + (m > 0 ? ' ' + m + 'min' : '')
  805. : 'Expire dans ' + m + 'min';
  806. if (h < 2) el.style.color = 'var(--amber)';
  807. if (h < 1) el.style.color = 'var(--danger)';
  808. }
  809. update();
  810. setInterval(update, 60000);
  811. })();
  812. </script>
  813. {% block body_js %}{% endblock %}
  814. </body>
  815. </html>