<!DOCTYPE html><html lang="{{ app.request.locale|default('fr') }}"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}WhileResume{% endblock %}</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <style> :root { --violet: #D0BEFF; --violet-deep: #8B6FC0; --violet-dark: #6B4FA0; --violet-soft: #F0EAFF; --violet-ultra: #F8F5FF; --violet-glow: rgba(208,190,255,0.35); --ink: #1E1E2E; --ink-80: #3D3D52; --ink-60: #62627A; --ink-40: #9090A7; --ink-20: #C8C8D6; --ink-10: #E6E6ED; --ink-05: #F2F2F6; --cream: #FBFAFF; --white: #FFFFFF; --emerald: #34C77B; --emerald-soft: #EDFCF4; --ocean: #4E8AE6; --ocean-soft: #EDF3FE; --amber: #E6A23C; --amber-soft: #FEF6E8; --rose: #E8457A; --rose-soft: #FEF0F4; --danger: #EF4444; --danger-soft: #FEF2F2; --font-display: 'Fraunces', serif; --font-body: 'Outfit', sans-serif; --font-mono: 'JetBrains Mono', monospace; --r-sm: 10px; --r-md: 14px; --r-lg: 20px; --r-xl: 28px; --r-full: 100px; --ease: cubic-bezier(0.16, 1, 0.3, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html { scroll-behavior: smooth; } body { font-family: var(--font-body); background: var(--cream); color: var(--ink); -webkit-font-smoothing: antialiased; line-height: 1.6; overflow-x: hidden; min-height: 100vh; } body::after { content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 9999; opacity: 0.018; 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"); background-size: 200px; } /* TOP BAR */ .topbar { position: fixed; top: 0; left: 0; right: 0; z-index: 90; display: flex; align-items: center; justify-content: space-between; padding: 0 40px; height: 60px; background: rgba(251,250,255,0.88); backdrop-filter: blur(16px) saturate(1.6); border-bottom: 1px solid rgba(208,190,255,0.15); } .topbar-logo { font-family: var(--font-display); font-size: 1.3rem; font-weight: 600; text-decoration: none; color: var(--ink); letter-spacing: -0.03em; } .topbar-logo span { color: var(--violet-dark); font-style: italic; } .topbar-right { display: flex; align-items: center; gap: 16px; } .topbar-save { display: none; align-items: center; gap: 6px; font-size: 0.75rem; color: var(--ink-60); } .topbar-save.visible { display: flex; } .topbar-save.saved { color: var(--emerald); } .topbar-save.saving { color: var(--ink-60); } .topbar-save.error { color: var(--danger); } .topbar-save-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } .topbar-save.saving .topbar-save-dot { animation: pulse 1.2s ease-in-out infinite; } .topbar-link { font-size: 0.82rem; font-weight: 500; color: var(--ink-60); text-decoration: none; transition: color 0.2s; } .topbar-link:hover { color: var(--violet-dark); } /* Bouton violet "Retour au dashboard" dans la topbar */ .topbar-btn-primary { display: inline-flex; align-items: center; gap: 8px; padding: 9px 18px; background: linear-gradient(135deg, var(--violet-deep), var(--violet-dark)); color: white; font-family: var(--font-body); font-size: 0.82rem; font-weight: 600; text-decoration: none; border-radius: var(--r-full); box-shadow: 0 4px 14px var(--violet-glow); transition: all 0.25s var(--ease); white-space: nowrap; } .topbar-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 8px 22px rgba(139, 111, 192, 0.35); background: linear-gradient(135deg, var(--violet-dark), var(--violet-deep)); } .topbar-btn-primary:active { transform: translateY(0); box-shadow: 0 3px 10px var(--violet-glow); } .topbar-btn-primary svg { flex-shrink: 0; } .topbar-avatar { width: 34px; height: 34px; border-radius: 50%; background: linear-gradient(135deg, var(--violet), var(--violet-deep)); color: white; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.72rem; } /* PAGE CONTAINER */ .page-container { min-height: 100vh; padding: 110px 100px 60px; position: relative; } .page-inner { max-width: 820px; margin: 0 auto; position: relative; } /* DECO BLOBS */ .page-container::before, .page-container::after { content: ''; position: fixed; border-radius: 50%; pointer-events: none; opacity: 0.4; z-index: 0; } .page-container::before { width: 520px; height: 520px; top: -120px; right: -160px; background: radial-gradient(circle, var(--violet-soft), transparent 70%); } .page-container::after { width: 400px; height: 400px; bottom: -80px; left: -120px; background: radial-gradient(circle, var(--ocean-soft), transparent 70%); } /* STEPS PROGRESS (top of page under topbar) */ .tunnel-progress { position: fixed; top: 60px; left: 0; right: 0; z-index: 80; background: rgba(251,250,255,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(208,190,255,0.1); padding: 16px 40px; } .tunnel-progress-inner { max-width: 820px; margin: 0 auto; display: flex; align-items: center; gap: 16px; } .tp-step { display: flex; align-items: center; gap: 10px; flex: 1; opacity: 0.4; transition: opacity 0.3s; } .tp-step.active, .tp-step.done { opacity: 1; } .tp-dot { width: 26px; height: 26px; border-radius: 50%; background: var(--ink-10); color: var(--ink-40); display: flex; align-items: center; justify-content: center; font-size: 0.72rem; font-weight: 700; flex-shrink: 0; transition: all 0.3s var(--ease-spring); } .tp-step.active .tp-dot { background: var(--violet-deep); color: white; box-shadow: 0 0 0 4px var(--violet-glow); } .tp-step.done .tp-dot { background: var(--emerald); color: white; } .tp-label { font-size: 0.78rem; font-weight: 500; color: var(--ink-60); white-space: nowrap; } .tp-step.active .tp-label { color: var(--violet-dark); font-weight: 600; } .tp-step.done .tp-label { color: var(--emerald); } .tp-line { flex: 1; height: 2px; background: var(--ink-10); position: relative; overflow: hidden; min-width: 20px; } .tp-line::after { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: var(--emerald); transform: scaleX(0); transform-origin: left; transition: transform 0.5s var(--ease); } .tp-line.done::after { transform: scaleX(1); } .page-container { padding-top: 150px; } /* BADGE / TITLES */ .badge { display: inline-flex; align-items: center; gap: 8px; padding: 6px 16px; border-radius: var(--r-full); font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 24px; background: var(--violet-soft); color: var(--violet-dark); } .badge-num { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; font-weight: 700; background: var(--violet-deep); color: white; } .stitle { font-family: var(--font-display); font-size: clamp(2rem, 4vw, 3rem); font-weight: 400; line-height: 1.15; letter-spacing: -0.03em; margin-bottom: 14px; } .stitle em { font-style: italic; color: var(--violet-dark); } .sdesc { font-size: 1.05rem; color: var(--ink-60); max-width: 540px; margin-bottom: 40px; font-weight: 300; } /* FORM ELEMENTS (communs) */ .fg { display: grid; gap: 16px; } .fg.c2 { grid-template-columns: 1fr 1fr; } .fg.c3 { grid-template-columns: 1fr 1fr 1fr; } .flabel { display: block; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-40); margin-bottom: 8px; } .flabel .req { color: var(--rose); margin-left: 2px; } .flabel .opt { opacity: 0.5; text-transform: none; letter-spacing: 0; } .finput, .ftextarea, .fselect { width: 100%; padding: 13px 16px; border: 1.5px solid var(--ink-10); border-radius: var(--r-md); font-family: var(--font-body); font-size: 0.92rem; color: var(--ink); background: var(--white); outline: none; transition: border-color 0.25s, box-shadow 0.25s; } .finput:focus, .ftextarea:focus, .fselect:focus { border-color: var(--violet); box-shadow: 0 0 0 4px var(--violet-glow); } .finput.invalid, .tag-input-wrap.invalid { border-color: var(--danger); background: var(--danger-soft); } .finput::placeholder, .ftextarea::placeholder { color: var(--ink-20); } .ftextarea { resize: vertical; min-height: 130px; line-height: 1.7; } .fselect { appearance: none; -webkit-appearance: none; 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>"); background-repeat: no-repeat; background-position: right 14px center; background-size: 16px; padding-right: 40px; cursor: pointer; } .field { margin-bottom: 14px; } .field:last-child { margin-bottom: 0; } .section-label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-40); margin: 32px 0 14px; } .section-label:first-of-type { margin-top: 0; } /* BUTTONS */ .btn-primary, .btn-secondary, .btn-ghost { display: inline-flex; align-items: center; justify-content: center; gap: 10px; padding: 15px 32px; border: none; border-radius: var(--r-full); font-family: var(--font-body); font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.3s var(--ease); text-decoration: none; } .btn-primary { background: linear-gradient(135deg, var(--violet), var(--violet-deep)); color: white; box-shadow: 0 6px 24px var(--violet-glow); } .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 10px 32px rgba(208,190,255,0.5); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .btn-secondary { background: var(--white); color: var(--ink-80); border: 1.5px solid var(--ink-10); } .btn-secondary:hover { border-color: var(--ink-20); background: var(--ink-05); } .btn-ghost { background: transparent; color: var(--ink-60); padding: 10px 18px; } .btn-ghost:hover { color: var(--ink); background: var(--ink-05); } .cta-row { display: flex; gap: 12px; margin-top: 36px; flex-wrap: wrap; } @keyframes pulse { 0%, 100% { opacity: .6; transform: scale(.9); } 50% { opacity: 1; transform: scale(1.1); } } @keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .fade-up { animation: fadeUp 0.6s var(--ease) both; } .fade-up.d1 { animation-delay: 0.08s; } .fade-up.d2 { animation-delay: 0.16s; } .fade-up.d3 { animation-delay: 0.24s; } .fade-up.d4 { animation-delay: 0.32s; } /* ═══════════════════════════════════ LOADER OVERLAY ═══════════════════════════════════ */ .loader-overlay { position: fixed; inset: 0; z-index: 9998; background: rgba(251,250,255,0.92); backdrop-filter: blur(8px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; opacity: 1; transition: opacity 0.4s var(--ease); pointer-events: all; } .loader-overlay.hidden { opacity: 0; pointer-events: none; } .loader-orb { width: 84px; height: 84px; position: relative; } .loader-ring { position: absolute; inset: 0; border-radius: 50%; border: 2.5px solid var(--violet-soft); } .loader-ring::after { content: ''; position: absolute; inset: -2.5px; border-radius: 50%; border: 3px solid transparent; border-top-color: var(--violet-deep); animation: spin 1.2s linear infinite; } .loader-ring:nth-child(2) { inset: 14px; } .loader-ring:nth-child(2)::after { border-top-color: var(--violet); animation-duration: 1.8s; animation-direction: reverse; } .loader-core { position: absolute; inset: 26px; border-radius: 50%; background: var(--violet-soft); display: flex; align-items: center; justify-content: center; } .loader-core svg { color: var(--violet-deep); animation: pulse 2.2s ease-in-out infinite; } .loader-text { font-family: var(--font-display); font-size: 1.05rem; font-weight: 500; color: var(--ink-80); letter-spacing: -0.01em; } .loader-sub { font-size: 0.82rem; color: var(--ink-40); font-weight: 400; max-width: 280px; text-align: center; margin-top: -16px; } @keyframes spin { to { transform: rotate(360deg); } } @media (max-width: 760px) { .page-container { padding: 150px 20px 40px; } .topbar { padding: 0 20px; } .tunnel-progress { padding: 14px 20px; } .tp-label { display: none; } .fg.c2, .fg.c3 { grid-template-columns: 1fr; } } {% block extra_css %}{% endblock %} </style></head><body><div class="loader-overlay{% if not show_loader|default(false) %} hidden{% endif %}" id="loaderOverlay"> <div class="loader-orb"> <div class="loader-ring"></div> <div class="loader-ring"></div> <div class="loader-core"> <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/> <polyline points="14 2 14 8 20 8"/> </svg> </div> </div> <div class="loader-text" id="loaderText">{{ 'generate2.loader.default_title'|trans({}, 'whr-public') }}</div> <div class="loader-sub" id="loaderSub">{{ 'generate2.loader.default_sub'|trans({}, 'whr-public') }}</div></div><header class="topbar"> {# Mode public : logo cliquable vers la homepage WhileResume (pas de dashboard puisque pas de compte) #} <a href="{{ path('locale_whileresume_homepage') }}"> <img src="/uploads/logo.png" style="margin-top:10px;" alt="WhileResume" /> </a> <div class="topbar-right"> <div class="topbar-save" id="saveIndicator"> <div class="topbar-save-dot"></div> <span id="saveText">{{ 'generate2.topbar.saved'|trans({}, 'whr-public') }}</span> </div> {# Mode public : pas de lien dashboard ni d'avatar — visiteur anonyme #} {# On garde un placeholder discret indiquant le temps restant pour rappeler les 48h #} {% if draft is defined and draft and draft.expiresAt %} <span class="topbar-link" id="cvpub-expires" data-expires-at="{{ draft.expiresAt|date('c') }}" style="font-size:0.78rem;color:var(--ink-40)"> {# Le JS calculera le temps restant et l'affichera ici #} </span> {% endif %} <a href="{% if app.request.locale == "fr" %}{{ path('locale_whileresume_homepage',{'_locale':app.request.locale}) }}{% else %}{{ path('whileresume_homepage') }}{% endif %}" class="topbar-btn-primary"> <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"> <path d="M3 12l9-9 9 9M5 10v10a1 1 0 001 1h4v-6h4v6h4a1 1 0 001-1V10"/> </svg> {{ 'generate2.topbar.backhomepage'|trans({}, 'whr-candidates') }} </a> </div></header>{% block progress %} <div class="tunnel-progress"> <div class="tunnel-progress-inner"> <div class="tp-step {{ current_step == 1 ? 'active' : (current_step > 1 ? 'done' : '') }}"> <div class="tp-dot">{{ current_step > 1 ? '✓' : '1' }}</div> <span class="tp-label">{{ 'generate2.progress.step_choice'|trans({}, 'whr-public') }}</span> </div> <div class="tp-line {{ current_step > 1 ? 'done' : '' }}"></div> <div class="tp-step {{ current_step == 2 ? 'active' : (current_step > 2 ? 'done' : '') }}"> <div class="tp-dot">{{ current_step > 2 ? '✓' : '2' }}</div> <span class="tp-label">{{ 'generate2.progress.step_theme'|trans({}, 'whr-public') }}</span> </div> <div class="tp-line {{ current_step > 2 ? 'done' : '' }}"></div> <div class="tp-step {{ current_step == 3 ? 'active' : (current_step > 3 ? 'done' : '') }}"> <div class="tp-dot">{{ current_step > 3 ? '✓' : '3' }}</div> <span class="tp-label">{{ 'generate2.progress.step_info'|trans({}, 'whr-public') }}</span> </div> </div> </div>{% endblock %}<main class="page-container"> <div class="page-inner"> {% block body %}{% endblock %} </div></main>{# ════════════════════════════════════════════════════════════════════════════ CONTEXT POUR LE JS (data-attributes) ════════════════════════════════════════════════════════════════════════════ Approche imperméable aux caractères de contrôle / encodage cassé : chaque trad est un attribut HTML séparé, escapé par Twig via |e('html_attr'). Si UNE trad est corrompue, ça affecte SEULEMENT cet attribut, pas le reste.#}{# Détecte si la requête courante utilise une route avec préfixe locale. Si oui, on génère TOUTES les URLs des routes du tunnel avec préfixe pour que la navigation conserve /fr/, /de/, etc. dans l'URL. #}{% set _current_route = app.request.attributes.get('_route') %}{% set _has_locale_prefix = _current_route starts with 'locale_' %}<div id="__cv_ctx_root__" style="display:none" data-api-base="{{ api_base|default('/api/cv-public')|e('html_attr') }}" data-jwt="{{ jwt|default('')|e('html_attr') }}" data-locale="{{ app.request.locale|default('fr')|e('html_attr') }}" data-is-public="{{ is_public|default(true) ? '1' : '0' }}" data-public-slug="{{ public_slug|default('')|e('html_attr') }}" data-user-first-name="{{ (user_data.first_name|default(''))|e('html_attr') }}" data-user-last-name="{{ (user_data.last_name|default(''))|e('html_attr') }}" data-user-email="{{ (user_data.email|default(''))|e('html_attr') }}" 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') : '' }}" 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') : '' }}" 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') : '' }}" 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') : '' }}" 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') : '' }}" data-i18n-saving="{{ 'generate2.topbar.saving'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-saved="{{ 'generate2.topbar.saved'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-save-error="{{ 'generate2.topbar.save_error'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-load-error="{{ 'generate2.form.error.load'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-analyze-error="{{ 'generate2.form.analyzing.error'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-generate-error="{{ 'generate2.form.error.generate'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-missing-fields="{{ 'generate2.form.error.missing_fields'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-generate-first="{{ 'generate2.form.error.generate_first'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-preview-loading="{{ 'generate2.done.preview_loading'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-preview-unavailable="{{ 'generate2.done.preview_unavailable'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-preview-fallback="{{ 'generate2.done.preview_fallback'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-download-error="{{ 'generate2.form.error.download'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-file-too-large="{{ 'generate2.form.upload.error_too_large'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-file-must-be-pdf="{{ 'generate2.form.upload.error_must_be_pdf'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-generating="{{ 'generate2.loader.generate_title'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-generating-hint="{{ 'generate2.loader.generate_sub'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-generate-cta="{{ 'generate2.form.cta_generate'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-generating-cta="{{ 'generate2.form.cta_generating'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-analyze="{{ 'generate2.loader.analyze_done_title'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-analyze-sub="{{ 'generate2.loader.analyze_done_sub'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-prefs="{{ 'generate2.loader.prefs_title'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-prefs-sub="{{ 'generate2.loader.prefs_sub'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-form-scratch="{{ 'generate2.loader.form_scratch_title'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-form-scratch-sub="{{ 'generate2.loader.form_scratch_sub'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-form-edit="{{ 'generate2.loader.form_edit_title'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-form-edit-sub="{{ 'generate2.loader.form_edit_sub'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-form-analyze="{{ 'generate2.loader.form_analyze_title'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-loader-form-analyze-sub="{{ 'generate2.loader.form_analyze_sub'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-network-error="{{ 'generate2.form.error.network'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-title-language="{{ 'generate2.form.item_title.language'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-title-experience="{{ 'generate2.form.item_title.experience'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-title-degree="{{ 'generate2.form.item_title.degree'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-title-certification="{{ 'generate2.form.item_title.certification'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-title-project="{{ 'generate2.form.item_title.project'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-language="{{ 'generate2.form.label.language'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-level="{{ 'generate2.form.label.level'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-position="{{ 'generate2.form.label.position'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-company="{{ 'generate2.form.label.company'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-start-date="{{ 'generate2.form.label.start_date'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-end-date="{{ 'generate2.form.label.end_date'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-city="{{ 'generate2.form.label.city'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-contract-type="{{ 'generate2.form.label.contract_type'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-description="{{ 'generate2.form.label.description'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-degree="{{ 'generate2.form.label.degree'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-school="{{ 'generate2.form.label.school'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-field="{{ 'generate2.form.label.field'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-cert-name="{{ 'generate2.form.label.cert_name'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-issuer="{{ 'generate2.form.label.issuer'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-year="{{ 'generate2.form.label.year'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-label-project-name="{{ 'generate2.form.label.project_name'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-lang-name="{{ 'generate2.form.placeholder.lang_name'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-lang-level="{{ 'generate2.form.placeholder.lang_level'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-position="{{ 'generate2.form.placeholder.position'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-company="{{ 'generate2.form.placeholder.company'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-present="{{ 'generate2.form.placeholder.end_date_present'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-choose="{{ 'generate2.common.choose'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-exp-desc="{{ 'generate2.form.placeholder.exp_desc'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-degree="{{ 'generate2.form.placeholder.degree'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-school="{{ 'generate2.form.placeholder.school'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-field="{{ 'generate2.form.placeholder.field'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-cert-name="{{ 'generate2.form.placeholder.cert_name'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-issuer="{{ 'generate2.form.placeholder.issuer'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-project-name="{{ 'generate2.form.placeholder.project_name'|trans({}, 'whr-public')|e('html_attr') }}" data-i18n-ph-project-desc="{{ 'generate2.form.placeholder.project_desc'|trans({}, 'whr-public')|e('html_attr') }}"></div><script> /** * Construction de window.__CV_CTX__ depuis les data-attributes du div root. * Approche imperméable aux caractères qui cassent : pas de json_encode, * pas de JSON inline, juste des attributs HTML standards. */ (function () { const FALLBACK = { apiBase: '/api/cv-public', jwt: null, locale: 'fr', user: { first_name: '', last_name: '', email: '' }, isPublic: true, publicSlug: '', routes: { choice: '', theme: '', form: '', register: '', generate: '' }, i18n: {} }; const root = document.getElementById('__cv_ctx_root__'); if (!root) { console.error('[CV] __cv_ctx_root__ introuvable, fallback utilisé'); window.__CV_CTX__ = FALLBACK; return; } const ds = root.dataset; // Construction de l'objet i18n depuis tous les data-i18n-* présents // Le dataset HTML5 fait déjà la conversion data-i18n-save-error -> i18nSaveError const i18n = {}; Object.keys(ds).forEach(key => { if (!key.startsWith('i18n')) return; const trKey = key.charAt(4).toLowerCase() + key.slice(5); i18n[trKey] = ds[key] || ''; }); window.__CV_CTX__ = { apiBase: ds.apiBase || FALLBACK.apiBase, jwt: ds.jwt || null, locale: ds.locale || FALLBACK.locale, isPublic: ds.isPublic === '1', publicSlug: ds.publicSlug || '', user: { first_name: ds.userFirstName || '', last_name: ds.userLastName || '', email: ds.userEmail || '' }, routes: { choice: ds.routeChoice || '', theme: ds.routeTheme || '', form: ds.routeForm || '', register: ds.routeRegister || '', generate: ds.routeGenerate || '' }, i18n: i18n }; })(); /** * MODE PUBLIC : pas de JWT à envoyer. */ window.authHeaders = function () { return { 'Content-Type': 'application/json' }; }; /** * Ajoute le slug au body d'une requête API publique. */ window.publicBody = function (payload) { return Object.assign({}, payload || {}, { slug: window.__CV_CTX__.publicSlug }); }; /** * Ajoute le slug en query string pour les GET. */ window.publicQuery = function () { const slug = window.__CV_CTX__.publicSlug; return slug ? '?slug=' + encodeURIComponent(slug) : ''; }; window.setSaveStatus = function (status) { const el = document.getElementById('saveIndicator'); const txt = document.getElementById('saveText'); if (!el) return; el.classList.remove('saving', 'saved', 'error'); el.classList.add(status, 'visible'); if (txt) { const i18n = window.__CV_CTX__.i18n || {}; txt.textContent = status === 'saving' ? (i18n.saving || 'Enregistrement…') : status === 'saved' ? (i18n.saved || 'Enregistré') : (i18n.saveError || 'Erreur'); } }; window.cvLoader = { show(text, sub) { const o = document.getElementById('loaderOverlay'); if (!o) return; const t = document.getElementById('loaderText'); const s = document.getElementById('loaderSub'); if (t && text) t.textContent = text; if (s && sub !== undefined) s.textContent = sub || ''; o.classList.remove('hidden'); }, hide() { const o = document.getElementById('loaderOverlay'); if (o) o.classList.add('hidden'); } }; /** * Filet de sécurité : si le loader reste visible 5s, fermeture forcée. */ window.addEventListener('load', () => { setTimeout(() => { const o = document.getElementById('loaderOverlay'); if (o && !o.classList.contains('hidden')) { console.warn('[CV] Loader bloqué après 5s, fermeture forcée'); o.classList.add('hidden'); } }, 5000); }); /** * Countdown 48h dans la topbar. */ (function () { const el = document.getElementById('cvpub-expires'); if (!el || !el.dataset.expiresAt) return; const expiresAt = new Date(el.dataset.expiresAt); function update() { const now = new Date(); const ms = expiresAt - now; if (ms <= 0) { el.textContent = 'Brouillon expiré'; el.style.color = 'var(--danger)'; return; } const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); el.textContent = h >= 1 ? 'Expire dans ' + h + 'h' + (m > 0 ? ' ' + m + 'min' : '') : 'Expire dans ' + m + 'min'; if (h < 2) el.style.color = 'var(--amber)'; if (h < 1) el.style.color = 'var(--danger)'; } update(); setInterval(update, 60000); })();</script>{% block body_js %}{% endblock %}</body></html>