Feat: page À propos — redesign complet avec carrousel équipe
All checks were successful
Deploy / Deploy to Production (push) Successful in 5m23s

- Fix about.json.php : lecture des vrais champs Kirby (intro writer,
  body blocks, files template member) — corrige le bug [object Object]
- About.svelte : redesign fidèle à la source (intro centrée, blocs
  mission/manifeste avec bordure verte, carrousel équipe horizontal
  3/2/1 items selon breakpoint, swipe tactile, prev/next + pagination)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-13 10:39:24 +01:00
parent 773be2840c
commit 61607f8cd7
3 changed files with 374 additions and 163 deletions

View file

@ -7,9 +7,17 @@ fields:
type: text
required: true
translate: false
width: 1/2
role:
label: Poste
type: text
required: true
translate: false
help: "Ex: CEO & Co-Founder"
placeholder: CEO & Co-Founder
width: 1/2
link:
label: Lien
type: url
translate: false
placeholder: https://fr.linkedin.com/in/…
help: S'ouvre au clic sur la card

View file

@ -1,31 +1,25 @@
<?php
$bodyBlocks = [];
foreach ($page->body()->toBlocks() as $block) {
if ($block->type() === 'text') {
$bodyBlocks[] = [
'type' => 'text',
'html' => $block->content()->text()->value()
];
}
}
$specificData = [
'intro' => [
'title' => $page->introTitle()->value(),
'text' => $page->introText()->value()
],
'mission' => [
'title' => $page->missionTitle()->value(),
'text' => $page->missionText()->toBlocks()
],
'manifesto' => [
'title' => $page->manifestoTitle()->value(),
'text' => $page->manifestoText()->toBlocks()
],
'team' => [
'title' => $page->teamTitle()->value(),
'members' => $page->teamMembers()->toStructure()->map(function($member) {
'intro' => $page->intro()->value(),
'body' => $bodyBlocks,
'team' => $page->files()->template('member')->sort('sort')->map(function ($file) {
return [
'name' => $member->name()->value(),
'role' => $member->role()->value(),
'bio' => $member->bio()->value(),
'photo' => $member->photo()->toFile()?->url(),
'linkedin' => $member->linkedin()->value(),
'twitter' => $member->twitter()->value()
'name' => $file->memberName()->value(),
'role' => $file->role()->value(),
'photo' => $file->url(),
];
})->values()
]
];
$pageData = array_merge($genericData, $specificData);

View file

@ -1,72 +1,151 @@
<script>
import { fade } from 'svelte/transition'
import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte'
import Footer from '@components/layout/Footer.svelte'
let { data } = $props()
const intro = $derived(data?.intro || {})
const mission = $derived(data?.mission || {})
const manifesto = $derived(data?.manifesto || {})
const team = $derived(data?.team || {})
const intro = $derived(data?.intro ?? '')
const body = $derived(data?.body ?? [])
const members = $derived(data?.team ?? [])
const isActive = $derived(slides.active?.id === 'about')
let sectionEl = $state(null)
// --- Carousel state ---
let currentSlide = $state(0)
let visibleItems = $state(3)
let isTransitioning = $state(false)
let touchStartX = $state(null)
const totalSlides = $derived(Math.max(1, Math.ceil(members.length / visibleItems)))
const carouselOffset = $derived(
members.length === 0 ? 0 :
currentSlide === totalSlides - 1
? Math.max(0, (members.length - visibleItems) * 300)
: currentSlide * visibleItems * 300
)
function updateVisibleItems() {
if (window.matchMedia('(max-width: 700px)').matches) visibleItems = 1
else if (window.matchMedia('(max-width: 912px)').matches) visibleItems = 2
else visibleItems = 3
}
function prevSlide() {
if (isTransitioning || currentSlide <= 0) return
isTransitioning = true
currentSlide--
setTimeout(() => { isTransitioning = false }, 800)
}
function nextSlide() {
if (isTransitioning || currentSlide >= totalSlides - 1) return
isTransitioning = true
currentSlide++
setTimeout(() => { isTransitioning = false }, 800)
}
function goToSlide(i) {
if (isTransitioning || i === currentSlide) return
isTransitioning = true
currentSlide = i
setTimeout(() => { isTransitioning = false }, 800)
}
// Reset scroll when slide leaves view
$effect(() => {
if (!isActive) sectionEl?.scrollTo(0, 0)
})
// Reset carousel when number of visible items changes
$effect(() => {
void visibleItems
currentSlide = 0
})
onMount(() => {
updateVisibleItems()
window.addEventListener('resize', updateVisibleItems)
return () => window.removeEventListener('resize', updateVisibleItems)
})
</script>
<div class="about page-scrollable" bind:this={sectionEl} transition:fade>
<section class="about__intro">
<h1>{intro.title || data?.title}</h1>
{#if intro.text}
<p class="about__intro-text">{@html intro.text}</p>
{/if}
</section>
<div class="about page-scrollable" bind:this={sectionEl}>
{#if mission.text}
<section class="about__section">
<h2>{mission.title}</h2>
<div class="about__section-content">
{@html mission.text}
<!-- Intro -->
<section class="about-intro">
<div class="about-intro-content">
{@html intro}
</div>
</section>
{/if}
{#if manifesto.text}
<section class="about__section">
<h2>{manifesto.title}</h2>
<div class="about__section-content">
{@html manifesto.text}
</div>
</section>
{/if}
{#if team.members && team.members.length > 0}
<section class="about__team">
<h2>{team.title}</h2>
<div class="about__team-grid">
{#each team.members as member}
<article class="team-card">
{#if member.photo}
<div class="team-card__photo">
<img src={member.photo} alt={member.name} />
<!-- Body blocks (Mission, Manifeste…) -->
{#if body.length > 0}
<section class="about-body">
{#each body as block}
{#if block.type === 'text'}
<div class="about-body-block">
{@html block.html}
</div>
{/if}
<div class="team-card__info">
<h3>{member.name}</h3>
<p class="team-card__role">{member.role}</p>
{#if member.bio}
<p class="team-card__bio">{member.bio}</p>
{/if}
</div>
</article>
{/each}
</section>
{/if}
<!-- Team carousel -->
{#if members.length > 0}
<section class="about-team">
<h2 class="about-team-heading">OUR TEAM</h2>
<div class="team-carousel-container">
<div
class="team-grid"
style="transform: translateX(-{carouselOffset}px)"
ontouchstart={(e) => { touchStartX = e.touches[0].clientX }}
ontouchend={(e) => {
if (touchStartX === null) return
const delta = touchStartX - e.changedTouches[0].clientX
if (Math.abs(delta) > 50) delta > 0 ? nextSlide() : prevSlide()
touchStartX = null
}}
>
{#each members as member}
<div class="team-member">
{#if member.photo}
<img src={member.photo} alt={member.name} class="team-member-image" draggable="false" />
{/if}
<h4 class="team-member-name">{member.name}</h4>
<p class="team-member-title">{member.role}</p>
</div>
{/each}
</div>
</div>
<div class="nav-buttons">
<button
class="nav-button"
disabled={currentSlide === 0}
onclick={prevSlide}
>← BEFORE</button>
<div class="pagination-indicator">
{#each { length: totalSlides } as _, i}
<button
class="pagination-dot"
class:active={i === currentSlide}
aria-label="Slide {i + 1}"
onclick={() => goToSlide(i)}
></button>
{/each}
</div>
<button
class="nav-button"
disabled={currentSlide === totalSlides - 1}
onclick={nextSlide}
>NEXT →</button>
</div>
</section>
{/if}
@ -77,116 +156,246 @@
<style>
.about {
min-height: 100vh;
padding: 8rem 2rem 4rem;
color: #fff;
overflow-y: auto;
overflow-x: hidden;
}
.about__intro {
max-width: 1200px;
margin: 0 auto 6rem;
/* ── Intro ── */
.about-intro {
display: flex;
justify-content: center;
padding: 8rem 3rem 2rem;
}
.about-intro-content {
max-width: 800px;
text-align: center;
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph);
white-space: pre-line;
}
.about__intro h1 {
font-size: clamp(2.5rem, 6vw, 5rem);
.about-intro-content :global(h1) {
font-family: "Terminal", sans-serif;
font-size: var(--font-size-title-main);
font-weight: normal;
white-space: pre-line;
margin-bottom: 2rem;
}
.about__intro-text {
font-size: clamp(1.1rem, 2vw, 1.5rem);
opacity: 0.9;
max-width: 900px;
/* Custom Kirby writer marks */
.about-intro-content :global(span.pixel) {
font-family: "Terminal", sans-serif;
}
.about-intro-content :global(span.green),
.about-intro-content :global(.green) {
color: #04fea0;
}
.about-intro-content :global(p) {
font-size: var(--font-size-subtitle);
font-weight: normal;
margin-top: 1.5rem;
text-align: center;
}
/* ── Body blocks ── */
.about-body {
max-width: 800px;
margin: 0 auto;
padding: 1rem 3rem 3rem;
}
.about__section {
max-width: 1200px;
margin: 4rem auto;
}
.about__section h2 {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: 2rem;
color: #04fea0;
}
.about__section-content {
line-height: 1.8;
opacity: 0.9;
}
.about__team {
max-width: 1400px;
margin: 6rem auto 0;
}
.about__team h2 {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: 3rem;
text-align: center;
color: #04fea0;
}
.about__team-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
}
.team-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 2rem;
border-radius: 8px;
transition: transform 0.3s, border-color 0.3s;
}
.team-card:hover {
transform: translateY(-5px);
border-color: #04fea0;
}
.team-card__photo {
margin-bottom: 1.5rem;
}
.team-card__photo img {
width: 100%;
height: 250px;
object-fit: cover;
border-radius: 4px;
}
.team-card h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.team-card__role {
color: #04fea0;
font-size: 0.9rem;
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.team-card__bio {
font-size: 0.95rem;
.about-body-block {
border-left: 2px solid #04fea0;
padding-left: 20px;
margin-top: 3rem;
text-align: left;
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph);
font-weight: normal;
white-space: pre-line;
line-height: 1.6;
}
.about-body-block :global(h3) {
font-family: "Terminal", sans-serif;
font-size: var(--font-size-subtitle);
color: #04fea0;
margin-bottom: 1rem;
}
.about-body-block :global(p) {
opacity: 0.9;
margin-bottom: 0.75rem;
}
/* ── Team ── */
.about-team {
display: flex;
flex-direction: column;
align-items: center;
padding: 5rem 2rem 5rem;
}
.about-team-heading {
font-family: "Terminal", sans-serif;
font-size: var(--font-size-title-main);
color: white;
margin-bottom: 40px;
}
.team-carousel-container {
width: 100%;
max-width: 1200px;
overflow: hidden;
padding: 10px 0;
margin-bottom: 20px;
user-select: none;
}
.team-grid {
display: flex;
flex-wrap: nowrap;
transition: transform 0.8s cubic-bezier(0.25, 0.8, 0.25, 1);
padding: 10px 0;
will-change: transform;
}
.team-member {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
width: 250px;
margin: 0 25px;
transition: transform 0.3s ease;
}
.team-member:hover {
transform: translateY(-5px);
}
.team-member-image {
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
object-fit: contain;
margin-bottom: 10px;
transition: transform 0.3s ease;
}
.team-member-image:hover {
transform: scale(1.03);
}
.team-member-name {
font-family: "Danzza Bold", sans-serif;
font-size: var(--font-size-paragraph-small);
text-align: center;
text-transform: uppercase;
margin-bottom: 3px;
}
.team-member-title {
font-family: "Danzza", sans-serif;
font-size: var(--font-size-caption);
text-align: center;
opacity: 0.8;
}
.about :global(.site-footer) {
margin-left: -2rem;
/* Nav */
.nav-buttons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 400px;
margin-top: 30px;
}
@media (max-width: 768px) {
.about {
padding: 6rem 1rem 3rem;
.nav-button {
color: #04fea0;
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph-small);
background: none;
border: none;
cursor: pointer;
padding: 8px 12px;
text-transform: uppercase;
border-radius: 4px;
transition: transform 0.3s ease, background-color 0.3s ease;
}
.about__team-grid {
grid-template-columns: 1fr;
.nav-button:hover:not(:disabled) {
transform: scale(1.05);
background-color: rgba(4, 254, 160, 0.1);
}
.nav-button:disabled {
opacity: 0.4;
cursor: default;
}
.pagination-indicator {
display: flex;
align-items: center;
gap: 16px;
}
.pagination-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
border: none;
cursor: pointer;
padding: 0;
transition: transform 0.3s ease, background-color 0.3s ease;
}
.pagination-dot.active {
background: #04fea0;
transform: scale(1.3);
}
/* ── Mobile (≤700px) ── */
@media (max-width: 700px) {
.about-intro {
padding: 6rem 1.25rem 1rem;
}
.about-intro-content :global(h1) {
font-size: var(--font-size-title-section-mobile);
}
.about-intro-content :global(p) {
font-size: var(--font-size-subtitle-mobile);
}
.about-body {
padding: 1rem 1.25rem 2rem;
}
.about-body-block {
font-size: var(--font-size-paragraph-small, 0.875rem);
}
.about-team {
padding: 3rem 1rem 3rem;
}
}
/* ── Tablet (701px912px) ── */
@media (min-width: 701px) and (max-width: 912px) {
.about-intro-content :global(h1) {
font-size: var(--font-size-title-section-tablet);
}
.about-intro-content :global(p) {
font-size: var(--font-size-subtitle-tablet);
}
}
</style>