add white-paper block type for articles

- Kirby block blueprint with page reference + editable bg/text colors
- PHP snippet renders the card with accessible markup (article, h3, button)
- WhitePaperDialog.svelte: native dialog with download form (a11y: labels, fieldset, autocomplete, focus management)
- Article.svelte: click detection on .wp-block__btn + dialog mount

refs #49

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-30 19:51:21 +02:00
parent d218bc47d7
commit 8481dc5f90
5 changed files with 615 additions and 0 deletions

View file

@ -0,0 +1,20 @@
name: Livre blanc
icon: download
fields:
whitePaper:
label: Livre blanc
type: pages
max: 1
query: site.find('livres-blancs').children
image:
cover: true
ratio: 4/3
bgColor:
label: Couleur de fond
type: color
default: "#ffffff"
textColor:
label: Couleur de texte
type: color
default: "#000000"

View file

@ -72,6 +72,8 @@ tabs:
extends: blocks/video
jeu:
extends: blocks/jeu
white-paper:
extends: blocks/white-paper
# Sidebar
sidebar:

View file

@ -0,0 +1,26 @@
<?php
/** @var \Kirby\Cms\Block $block */
$wp = $block->whitePaper()->toPage();
if (!$wp) return;
$cover = $wp->cover()->toFile()?->url();
$bgColor = $block->bgColor()->isNotEmpty() ? $block->bgColor()->value() : '#ffffff';
$textColor = $block->textColor()->isNotEmpty() ? $block->textColor()->value() : '#000000';
$style = 'background:' . htmlspecialchars($bgColor, ENT_QUOTES, 'UTF-8') . ';color:' . htmlspecialchars($textColor, ENT_QUOTES, 'UTF-8');
?>
<article class="wp-block" style="<?= $style ?>">
<div class="wp-block__content">
<p class="wp-block__label">Livre blanc</p>
<h3 class="wp-block__title"><?= html($wp->title()) ?></h3>
<?php if ($wp->intro()->isNotEmpty()): ?>
<p class="wp-block__intro"><?= strip_tags($wp->intro()->value()) ?></p>
<?php endif ?>
<button class="button with-icon download-icon wp-block__btn" type="button" data-uri="<?= htmlspecialchars($wp->uri(), ENT_QUOTES, 'UTF-8') ?>">
Téléchargement
</button>
</div>
<?php if ($cover): ?>
<img class="wp-block__cover" src="<?= htmlspecialchars($cover, ENT_QUOTES, 'UTF-8') ?>" alt="<?= html($wp->title()) ?>" loading="lazy" />
<?php endif ?>
</article>

View file

@ -0,0 +1,478 @@
<script>
import { locale } from '@state/locale.svelte'
import { t } from '@i18n'
let { uri = null, onClose } = $props()
let dialogEl = $state(null)
let firstFieldEl = $state(null)
let firstName = $state('')
let lastName = $state('')
let company = $state('')
let role = $state('')
let email = $state('')
let consent = $state(false)
let honeypot = $state('')
let submitting = $state(false)
let status = $state(null) // null | 'success' | 'error'
let isEmailValid = $derived.by(() => {
const re = /^[\w\-\.]+@([\w-]+\.)+[\w-]{2,}$/gm
return re.test(email)
})
let isDownloadable = $derived.by(() =>
firstName.length > 0 &&
lastName.length > 0 &&
company.length > 0 &&
role.length > 0 &&
email.length > 0 &&
isEmailValid &&
consent
)
$effect(() => {
if (!dialogEl) return
if (uri) {
dialogEl.showModal()
// Focus premier champ après ouverture
setTimeout(() => firstFieldEl?.focus(), 50)
} else if (dialogEl.open) {
dialogEl.close()
resetForm()
}
})
function resetForm() {
firstName = ''
lastName = ''
company = ''
role = ''
email = ''
consent = false
honeypot = ''
submitting = false
status = null
}
function handleCancel() {
onClose?.()
}
async function handleSubmit(e) {
e.preventDefault()
if (!consent || !uri) return
submitting = true
status = null
try {
const prefix = locale.current === 'en' ? '/en' : ''
const res = await fetch(`${prefix}/${uri}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstName, lastName, company, role, email, _hp: honeypot })
})
const result = await res.json()
if (result.fileUrl) {
window.open(result.fileUrl, '_blank')
status = 'success'
} else {
status = 'error'
}
} catch {
status = 'error'
} finally {
submitting = false
}
}
</script>
<!-- La dialog se ferme nativement avec Escape (événement cancel) -->
<dialog
bind:this={dialogEl}
class="wp-dialog"
aria-labelledby="wp-dialog-title"
aria-modal="true"
oncancel={handleCancel}
>
<div class="wp-dialog__inner" role="document">
<button
class="wp-dialog__close"
type="button"
aria-label="Fermer"
onclick={handleCancel}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
{#if status === 'success'}
<div class="wp-dialog__success">
<img src="/assets/img/smiley.svg" alt="" class="wp-dialog__smiley" aria-hidden="true" />
<p class="wp-dialog__success-heading">{t('wp_success_heading')}</p>
<p class="wp-dialog__success-sub">{t('wp_success_sub')}</p>
<button class="wp-dialog__done" type="button" onclick={handleCancel}>
Fermer
</button>
</div>
{:else}
<div class="wp-dialog__form-wrap">
<p class="wp-dialog__intro" id="wp-dialog-title">{t('wp_form_intro')}</p>
<form class="wp-dialog__form" onsubmit={handleSubmit} novalidate>
<fieldset class="wp-dialog__fieldset">
<legend class="sr-only">Identité</legend>
<div class="wp-dialog__row">
<div class="wp-dialog__field">
<label class="sr-only" for="wp-firstname">{t('wp_firstname')}</label>
<input
id="wp-firstname"
class="wp-dialog__input"
type="text"
placeholder={t('wp_firstname')}
autocomplete="given-name"
required
aria-required="true"
bind:value={firstName}
bind:this={firstFieldEl}
/>
</div>
<div class="wp-dialog__field">
<label class="sr-only" for="wp-lastname">{t('wp_lastname')}</label>
<input
id="wp-lastname"
class="wp-dialog__input"
type="text"
placeholder={t('wp_lastname')}
autocomplete="family-name"
required
aria-required="true"
bind:value={lastName}
/>
</div>
</div>
</fieldset>
<div class="wp-dialog__field">
<label class="sr-only" for="wp-company">{t('wp_company')}</label>
<input
id="wp-company"
class="wp-dialog__input"
type="text"
placeholder={t('wp_company')}
autocomplete="organization"
required
aria-required="true"
bind:value={company}
/>
</div>
<div class="wp-dialog__field">
<label class="sr-only" for="wp-role">{t('wp_role')}</label>
<input
id="wp-role"
class="wp-dialog__input"
type="text"
placeholder={t('wp_role')}
autocomplete="organization-title"
required
aria-required="true"
bind:value={role}
/>
</div>
<div class="wp-dialog__field">
<label class="sr-only" for="wp-email">{t('wp_email')}</label>
<input
id="wp-email"
class="wp-dialog__input"
type="email"
placeholder={t('wp_email')}
autocomplete="email"
required
aria-required="true"
bind:value={email}
/>
</div>
<!-- Honeypot anti-spam -->
<div aria-hidden="true" class="wp-dialog__hp">
<label for="wp-website">Website</label>
<input
id="wp-website"
type="text"
name="website"
tabindex="-1"
autocomplete="off"
bind:value={honeypot}
/>
</div>
<label class="wp-dialog__consent">
<input type="checkbox" bind:checked={consent} required aria-required="true" />
<span>{t('wp_consent')}</span>
</label>
{#if status === 'error'}
<p class="wp-dialog__error" role="alert">{t('wp_error')}</p>
{/if}
<button
class="wp-dialog__submit"
type="submit"
disabled={submitting || !isDownloadable}
aria-disabled={submitting || !isDownloadable}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M12 16L7 11H10V4H14V11H17L12 16Z" fill="currentColor"/>
<path d="M5 20H19V18H5V20Z" fill="currentColor"/>
</svg>
{t('wp_download')}
</button>
</form>
</div>
{/if}
</div>
</dialog>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.wp-dialog {
position: fixed;
inset: 0;
margin: auto;
width: min(560px, 92vw);
max-height: 90vh;
background: #0d0e22;
color: #fff;
border: none;
border-radius: 12px;
padding: 0;
overflow: hidden;
}
.wp-dialog::backdrop {
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
}
.wp-dialog__inner {
position: relative;
padding: 2.5rem;
overflow-y: auto;
max-height: 90vh;
}
.wp-dialog__close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 0.25rem;
line-height: 0;
transition: color 0.2s;
}
.wp-dialog__close:hover {
color: #fff;
}
.wp-dialog__intro {
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph);
line-height: 1.5;
margin-bottom: 1.75rem;
padding-right: 2rem;
}
.wp-dialog__form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.wp-dialog__fieldset {
border: none;
padding: 0;
margin: 0;
}
.wp-dialog__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.wp-dialog__field {
display: contents;
}
.wp-dialog__input {
width: 100%;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
padding: 0.65rem 0.9rem;
color: #fff;
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph);
transition: border-color 0.2s;
}
.wp-dialog__input:focus {
outline: none;
border-color: var(--color-primary);
}
.wp-dialog__input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.wp-dialog__hp {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.wp-dialog__consent {
display: flex;
align-items: flex-start;
gap: 0.75rem;
cursor: pointer;
margin-top: 0.25rem;
}
.wp-dialog__consent input[type="checkbox"] {
flex-shrink: 0;
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--color-primary);
cursor: pointer;
}
.wp-dialog__consent span {
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph-small);
color: rgba(255, 255, 255, 0.7);
line-height: 1.4;
}
.wp-dialog__error {
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph-small);
color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
padding: 0.5rem 0.75rem;
border-radius: 4px;
}
.wp-dialog__submit {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
margin-top: 0.5rem;
padding: 0.75rem 2rem;
background: #04fea0;
color: #000;
border: 2px solid #04fea0;
border-radius: 0;
font-family: "Danzza", sans-serif;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.wp-dialog__submit:hover:not(:disabled) {
background: #03d98c;
border-color: #03d98c;
}
.wp-dialog__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Écran de succès */
.wp-dialog__success {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
padding: 1rem 0 0.5rem;
text-align: center;
}
.wp-dialog__smiley {
width: 72px;
height: 72px;
}
.wp-dialog__success-heading {
font-family: "Danzza bold", sans-serif;
font-size: var(--font-size-title-section);
color: var(--color-primary);
line-height: 1.2;
white-space: pre-line;
}
.wp-dialog__success-sub {
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph);
color: rgba(255, 255, 255, 0.6);
}
.wp-dialog__done {
margin-top: 0.5rem;
padding: 0.6rem 2rem;
background: none;
border: 1px solid rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.7);
border-radius: 4px;
cursor: pointer;
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph-small);
transition: border-color 0.2s, color 0.2s;
}
.wp-dialog__done:hover {
border-color: #fff;
color: #fff;
}
/* Mobile */
@media (max-width: 700px) {
.wp-dialog__inner {
padding: 1.5rem;
}
.wp-dialog__row {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -7,9 +7,12 @@
import { t } from '@i18n'
import { onMount } from 'svelte'
import Footer from '@components/layout/Footer.svelte'
import WhitePaperDialog from '@components/WhitePaperDialog.svelte'
let { data, onBack } = $props()
let activeWhitePaperUri = $state(null)
let copySuccess = $state(false)
let copyTimer = null
@ -18,6 +21,15 @@
if (!data.body) return
const timer = setTimeout(() => {
// Boutons livre-blanc → ouvre la dialog
document.querySelectorAll('.wp-block__btn').forEach(btn => {
if (btn.dataset.initialized) return
btn.dataset.initialized = 'true'
btn.addEventListener('click', () => {
activeWhitePaperUri = btn.dataset.uri
})
})
document.querySelectorAll('.iframe-game-container').forEach(container => {
if (container.dataset.initialized) return
container.dataset.initialized = 'true'
@ -180,6 +192,8 @@
<Footer />
</div>
<WhitePaperDialog uri={activeWhitePaperUri} onClose={() => activeWhitePaperUri = null} />
<style>
.article-wrapper {
grid-area: 6 / 1 / span 15 / span 20;
@ -546,6 +560,81 @@
font-size: var(--font-size-paragraph);
}
/* --- Bloc livre blanc --- */
.article-body :global(.wp-block) {
position: relative;
display: flex;
align-items: center;
gap: 2rem;
border-radius: 16px;
padding: 2.5rem 4rem;
margin: 2.5rem 0;
width: 170%;
margin-left: -35%;
}
.article-body :global(.wp-block__content) {
width: 50%;
z-index: 1;
}
.article-body :global(.wp-block__label) {
font-family: "Terminal", sans-serif;
font-size: var(--font-size-paragraph);
color: var(--color-primary);
text-transform: uppercase;
margin: 0;
}
.article-body :global(.wp-block__title) {
font-size: var(--font-size-title-section);
font-weight: 700;
font-family: "Danzza", sans-serif;
text-transform: uppercase;
line-height: 1.1;
margin: 0;
}
.article-body :global(.wp-block__intro) {
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph);
line-height: 1.5;
opacity: 0.5;
margin: 0;
}
.article-body :global(.wp-block__btn) {
margin-top: 1rem;
}
.article-body :global(.wp-block__btn:hover) {
background: #03d98c;
border-color: #03d98c;
}
.article-body :global(.wp-block__cover) {
position: absolute;
width: 50vw;
right: -18rem;
top: -5vw;
}
@media (max-width: 700px) {
.article-body :global(.wp-block) {
flex-direction: column;
padding: 1.75rem 1.25rem;
}
.article-body :global(.wp-block__cover) {
width: 60%;
max-width: none;
}
.article-body :global(.wp-block__title) {
font-size: var(--font-size-title-section-mobile);
}
}
/* --- Mobile --- */
@media (max-width: 700px) {
.article {