All checks were successful
Deploy / Deploy to Production (push) Successful in 19s
closes #3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
439 lines
11 KiB
Svelte
439 lines
11 KiB
Svelte
<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 (701px–912px) ── */
|
||
@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>
|