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>