since3-portal/modules/templates/catalog/blocks/about.tsx
2026-10-11 18:22:32 +02:00

151 lines
7.9 KiB
TypeScript

"use client"
import { AnimatePresence, motion, useInView } from "motion/react"
import { useEffect, useEffectEvent, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { TImage } from "../shared/media"
import { Reveal, ScrollWords, ease } from "../shared/motion"
import type { StatementData, StoryKapitelData, TextBildData } from "./definitions"
import type { BlockProps } from "./types"
import { container, easeInOut, Eyebrow, Heading, InkImage } from "./ui"
export function Statement({ data }: BlockProps<StatementData>) {
return (
<section className={cn(container, "py-28 md:py-44")}>
<div className="grid gap-10 md:grid-cols-12 md:gap-6">
<Reveal className="md:col-span-3">
<Eyebrow>{data.eyebrow}</Eyebrow>
</Reveal>
<div className="md:col-span-9">
<ScrollWords text={data.text} className="font-t-display text-[clamp(1.9rem,4.2vw,4.25rem)] leading-[1.08] tracking-[-0.02em]" />
{data.author.trim() && (
<Reveal className="mt-12">
<p className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span aria-hidden className="h-px w-10 bg-t-accent" />
<span className="font-medium">{data.author}</span>
<span className="text-t-muted">{data.role}</span>
</p>
</Reveal>
)}
</div>
</div>
</section>
)
}
type Chapter = StoryKapitelData["items"][number]
// Geschichte in Kapiteln: links ein stehendes Bild, das sich mit dem aktiven Kapitel von unten neu aufdeckt
export function StoryKapitel({ data }: BlockProps<StoryKapitelData>) {
const [active, setActive] = useState(0)
const current = Math.min(active, Math.max(0, data.items.length - 1))
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid gap-8 md:grid-cols-12 md:gap-6">
<Reveal className="md:col-span-3">
<Eyebrow>{data.eyebrow}</Eyebrow>
</Reveal>
<Heading text={data.title} className="md:col-span-9" />
</div>
<div className="mt-12 grid gap-6 md:mt-20 md:grid-cols-12">
<div className="hidden md:col-span-6 md:block">
<div className="sticky top-[12vh] h-[76vh] overflow-hidden rounded-[1.5rem] bg-t-surface">
{data.items.map((item, i) => (
<motion.div
key={i}
className="absolute inset-0"
style={{ zIndex: i }}
initial={false}
animate={{ clipPath: i <= current ? "inset(0% 0% 0% 0%)" : "inset(100% 0% 0% 0%)" }}
transition={{ duration: 1.1, ease: easeInOut }}
>
<motion.div className="absolute inset-0" initial={false} animate={{ scale: i === current ? 1 : 1.12 }} transition={{ duration: 1.6, ease }}>
<TImage src={item.image} alt={item.title} sizes="50vw" />
</motion.div>
</motion.div>
))}
<div aria-hidden className="absolute inset-0 z-[40] bg-gradient-to-t from-black/55 via-transparent to-transparent" />
<div className="absolute bottom-6 left-7 z-[41] overflow-hidden font-t-display text-[clamp(3rem,6vw,6.5rem)] leading-none tracking-[-0.04em] text-white">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={current}
className="block"
initial={{ y: "100%" }}
animate={{ y: "0%" }}
exit={{ y: "-100%" }}
transition={{ duration: 0.7, ease: easeInOut }}
>
{data.items[current]?.year}
</motion.span>
</AnimatePresence>
</div>
<div aria-hidden className="absolute right-7 bottom-8 z-[41] flex gap-1.5">
{data.items.map((_, i) => (
<span key={i} className={cn("h-1 rounded-full transition-all duration-500", i === current ? "w-8 bg-white" : "w-1.5 bg-white/50")} />
))}
</div>
</div>
</div>
<div className="flex flex-col md:col-span-5 md:col-start-8">
{data.items.map((item, i) => (
<ChapterText key={i} item={item} active={i === current} onEnter={() => setActive(i)} />
))}
</div>
</div>
</section>
)
}
function ChapterText({ item, active, onEnter }: { item: Chapter; active: boolean; onEnter: () => void }) {
const ref = useRef<HTMLElement>(null)
// Aktiv, sobald das Kapitel die Bildschirmmitte kreuzt
const inView = useInView(ref, { margin: "-45% 0px -45% 0px" })
const enter = useEffectEvent(onEnter)
useEffect(() => {
if (inView) enter()
}, [inView])
return (
<article
ref={ref}
className={cn("flex flex-col justify-center py-10 transition-opacity duration-700 md:min-h-[76vh] md:py-0", active ? "opacity-100" : "md:opacity-30")}
>
<div className="relative mb-8 aspect-[4/5] overflow-hidden rounded-[1.25rem] bg-t-surface md:hidden">
<TImage src={item.image} alt={item.title} sizes="100vw" />
</div>
<p className="font-t-display text-5xl leading-none tracking-[-0.03em] text-t-accent">{item.year}</p>
<h3 className="mt-6 font-t-display text-3xl tracking-[-0.02em] md:text-4xl">{item.title}</h3>
<p className="mt-4 max-w-md text-lg leading-relaxed text-t-muted">{item.text}</p>
</article>
)
}
export function TextBild({ data }: BlockProps<TextBildData>) {
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid items-center gap-12 md:grid-cols-12 md:gap-6">
<Reveal className="md:col-span-5">
<figure>
<InkImage src={data.image} alt={data.caption} sizes="(min-width: 768px) 40vw, 100vw" className="aspect-[3/4] rounded-[1.25rem]" />
{data.caption.trim() && <figcaption className="mt-3 text-xs text-t-muted">{data.caption}</figcaption>}
</figure>
</Reveal>
<div className="md:col-span-6 md:col-start-7">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
<Reveal delay={0.1}>
<p className="mt-8 text-lg leading-relaxed text-t-fg/80 first-letter:float-left first-letter:mt-1.5 first-letter:mr-3 first-letter:font-t-display first-letter:text-[4.6rem] first-letter:leading-[0.78] first-letter:text-t-accent">
{data.text}
</p>
{data.quote.trim() && (
<blockquote className="mt-10 border-l-2 border-t-accent pl-6 font-t-display text-2xl leading-snug italic md:text-3xl">„{data.quote}“</blockquote>
)}
{data.signature.trim() && <p className="mt-10 font-t-display text-3xl italic">{data.signature}</p>}
</Reveal>
</div>
</div>
</section>
)
}