world-game/src/views/About.svelte
isUnknown e2df8d04e0
All checks were successful
Deploy / Deploy to Production (push) Successful in 19s
feat(about): team members cliquables si champ link rempli + fix ARIA warning
closes #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 09:30:16 +01:00

439 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte'
import { navigation } from '@state/navigation.svelte'
import { t } from '@i18n'
import Footer from '@components/layout/Footer.svelte'
let { data } = $props()
const heading = $derived(data?.heading ?? '')
const subtitle = $derived(data?.subtitle ?? '')
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 golden-grid page-scrollable" bind:this={sectionEl} onscroll={() => navigation.setScrolled(sectionEl.scrollTop > 100)}>
<div class="page-container">
<!-- Intro -->
<section class="about-intro">
<div class="about-intro-content">
{#if heading}<h1 class="heading">{heading}</h1>{/if}
<p class="subtitle">{subtitle}</p>
</div>
</section>
<!-- 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}
{/each}
</section>
{/if}
<!-- Team carousel -->
{#if members.length > 0}
<section class="about-team">
<h2 class="about-team-heading">{t('our_team')}</h2>
<div class="team-carousel-container">
<div
class="team-grid"
role="list"
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}
{#if member.link}
<a href={member.link} target="_blank" rel="noopener noreferrer" class="team-member" role="listitem">
{#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>
</a>
{:else}
<div class="team-member" role="listitem">
{#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>
{/if}
{/each}
</div>
</div>
<div class="nav-buttons">
<button
class="nav-button"
disabled={currentSlide === 0}
onclick={prevSlide}
>{t('prev_slide')}</button>
<ul class="pagination-indicator">
{#each { length: totalSlides } as _, i}
<li>
<button
class="pagination-dot"
class:active={i === currentSlide}
aria-label="Slide {i + 1}"
onclick={() => goToSlide(i)}
></button>
</li>
{/each}
</ul>
<button
class="nav-button"
disabled={currentSlide === totalSlides - 1}
onclick={nextSlide}
>{t('next_slide')}</button>
</div>
</section>
{/if}
<Footer />
</div>
</div>
<style>
.about {
min-height: 100vh;
color: #fff;
overflow-y: auto;
overflow-x: hidden;
}
.page-container {
grid-area: 6/6/span 7/span 10;
height: 100%;
place-self: center;
text-align: center;
white-space: pre-line;
width: 100%;
}
/* ── Intro ── */
.about-intro {
display: flex;
justify-content: center;
}
.about-intro-content {
text-align: center;
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph);
white-space: pre-line;
}
.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;
}
/* 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 .subtitle {
font-size: var(--font-size-subtitle);
font-weight: normal;
margin-top: 1.5rem;
text-align: center;
margin-top: 3.125rem;
padding-left: 1.25rem;
}
/* ── Body blocks ── */
.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: Danzza Medium,sans-serif;
font-size: var(--font-size-paragraph);
margin-bottom: 1rem;
}
.about-body-block :global(h3::before) {
content: '';
display: inline-block;
width: .6rem;
height: .6rem;
background-color: var(--color-primary);
margin-right: .6rem;
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;
}
a.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;
}
a.team-member .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;
}
.pagination-indicator li {
list-style: none;
}
/* Nav */
.nav-buttons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 400px;
margin-top: 30px;
}
.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;
}
.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>