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

275 lines
14 KiB
TypeScript
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.

"use client"
import {
animate,
AnimatePresence,
motion,
useAnimationFrame,
useInView,
useMotionValue,
useMotionValueEvent,
useReducedMotionConfig,
useScroll,
useTransform,
} from "motion/react"
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react"
import { cn } from "@/lib/utils"
import { TImage } from "../shared/media"
import { HorizontalScroll, ease } from "../shared/motion"
import type { GalerieKarussellData, ProjekteIndexData, ProjekteQuerlaufData } from "./definitions"
import type { BlockProps } from "./types"
import { container, Eyebrow, Heading } from "./ui"
const pad = (n: number) => String(n).padStart(2, "0")
const number = (i: number) => pad(i + 1)
// ── Querlauf ─────────────────────────────────────────────────────────────────
// Projekte gleiten beim Scrollen seitlich vorbei; die Bildhöhe richtet sich nach dem Bildschirm
export function ProjekteQuerlauf({ data }: BlockProps<ProjekteQuerlaufData>) {
return (
<HorizontalScroll
header={
<div className={cn(container, "mb-8 grid gap-4 md:mb-12 md:grid-cols-12 md:items-end")}>
<div className="md:col-span-8">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-5 text-[clamp(2.25rem,5vw,5rem)]" />
</div>
<p className="text-sm text-t-muted md:col-span-4 md:text-right">
{pad(data.items.length)} Projekte · weiterscrollen
</p>
</div>
}
trackClassName="items-end gap-4 px-5 md:gap-8 md:px-10"
footer={(progress) => (
<div className={cn(container, "mt-8 md:mt-10")}>
<div className="h-px w-full bg-t-fg/15">
<motion.div style={{ scaleX: progress }} className="h-full origin-left bg-t-accent" />
</div>
</div>
)}
>
{data.items.map((item, i) => (
<article key={i} className="group shrink-0">
<div
className={cn(
"relative overflow-hidden rounded-[1.25rem] bg-t-surface",
i % 2 === 0 ? "aspect-[4/3] h-[40vh] md:h-[50vh]" : "aspect-[3/4] h-[46vh] md:h-[58vh]"
)}
>
<TImage src={item.image} alt={item.title} sizes="(min-width: 768px) 50vw, 90vw" className="transition-transform duration-[1.2s] ease-out group-hover:scale-105" />
</div>
<div className="mt-4 flex items-baseline justify-between gap-6">
<h3 className="font-t-display text-2xl tracking-[-0.02em] md:text-3xl">{item.title}</h3>
<span className="text-sm whitespace-nowrap text-t-muted">
{[item.category, item.year].filter(Boolean).join(" · ")}
</span>
</div>
</article>
))}
</HorizontalScroll>
)
}
// ── Index mit Bildspur ───────────────────────────────────────────────────────
type TrailImage = { id: number; x: number; y: number; src: string; rotate: number }
// Swiss-Liste; mit der Maus entsteht eine Spur aus Projektbildern, die nach kurzer Zeit verblasst
export function ProjekteIndex({ data }: BlockProps<ProjekteIndexData>) {
const last = useRef({ x: -1e4, y: -1e4, count: 0 })
const timers = useRef(new Set<ReturnType<typeof setTimeout>>())
const [trail, setTrail] = useState<TrailImage[]>([])
const images = data.items.map((item) => item.image).filter((src) => src.trim())
useEffect(() => {
const pending = timers.current
return () => pending.forEach((timer) => clearTimeout(timer))
}, [])
function onMove(event: PointerEvent<HTMLElement>) {
if (event.pointerType !== "mouse" || images.length === 0) return
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const previous = last.current
if (Math.hypot(x - previous.x, y - previous.y) < 90) return
previous.x = x
previous.y = y
previous.count += 1
const image = { id: previous.count, x, y, src: images[previous.count % images.length], rotate: ((previous.count * 37) % 17) - 8 }
setTrail((current) => [...current.slice(-6), image])
const timer = setTimeout(() => {
setTrail((current) => current.filter((entry) => entry.id !== image.id))
timers.current.delete(timer)
}, 700)
timers.current.add(timer)
}
return (
<section onPointerMove={onMove} className={cn(container, "relative py-24 md:py-36")}>
<div className="grid gap-6 md:grid-cols-12 md:items-end">
<div className="md:col-span-8">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-5" />
</div>
<p className="font-t-display text-4xl text-t-muted md:col-span-4 md:text-right md:text-6xl">({pad(data.items.length)})</p>
</div>
<div className="mt-12 border-t border-t-fg/15 md:mt-16">
<div className="hidden grid-cols-12 gap-4 border-b border-t-fg/15 py-3 text-[11px] tracking-[0.22em] text-t-muted uppercase md:grid">
<span className="col-span-1">Nº</span>
<span className="col-span-5">Projekt</span>
<span className="col-span-3">Ort</span>
<span className="col-span-2">Kategorie</span>
<span className="col-span-1 text-right">Jahr</span>
</div>
{data.items.map((item, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -8% 0px" }}
transition={{ duration: 0.7, ease, delay: i * 0.05 }}
className="group grid grid-cols-12 items-baseline gap-x-4 gap-y-2 border-b border-t-fg/15 py-6 md:py-8"
>
<span className="col-span-2 text-sm text-t-muted tabular-nums md:col-span-1">{number(i)}</span>
<h3 className="col-span-10 font-t-display text-[clamp(1.75rem,3.2vw,3.25rem)] leading-none tracking-[-0.03em] transition-transform duration-500 ease-out md:col-span-5 md:group-hover:translate-x-3">
{item.title}
</h3>
<span className="col-span-6 col-start-3 text-sm text-t-muted md:col-span-3 md:col-start-auto md:text-base">{item.location}</span>
<span className="hidden text-t-muted md:col-span-2 md:block">{item.category}</span>
<span className="col-span-4 text-right text-sm tabular-nums md:col-span-1 md:text-base">{item.year}</span>
<div className="relative col-span-12 mt-3 aspect-[16/10] overflow-hidden rounded-xl bg-t-surface md:hidden">
<TImage src={item.image} alt={item.title} sizes="100vw" />
</div>
</motion.div>
))}
</div>
<div aria-hidden className="pointer-events-none absolute inset-0 z-10 hidden overflow-hidden md:block">
<AnimatePresence>
{trail.map((image) => (
<motion.div
key={image.id}
className="absolute w-52 overflow-hidden rounded-xl bg-t-surface shadow-[0_30px_60px_-20px_rgba(0,0,0,0.5)]"
style={{ left: image.x, top: image.y, rotate: image.rotate, x: "-50%", y: "-50%" }}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85, transition: { duration: 0.35 } }}
transition={{ duration: 0.4, ease }}
>
<div className="relative aspect-[4/5]">
<TImage src={image.src} sizes="208px" />
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</section>
)
}
// ── 3D-Karussell ─────────────────────────────────────────────────────────────
type Drag = { startX: number; startValue: number; lastX: number; lastTime: number; velocity: number }
// Bilder auf einem Zylinder: dreht sich langsam von selbst, mit dem Scrollen und per Ziehen (mit Schwung).
// Breite und Radius rechnet CSS aus (--w, --n), damit es auf jeder Bildschirmgröße passt.
export function GalerieKarussell({ data }: BlockProps<GalerieKarussellData>) {
const ref = useRef<HTMLElement>(null)
const images = data.images.filter((image) => image.src.trim())
const count = Math.max(images.length, 1)
const step = 360 / count
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
const scrolled = useTransform(scrollYProgress, [0, 1], [0, -200])
const drag = useMotionValue(0)
const spin = useMotionValue(0)
const rotation = useTransform(() => scrolled.get() + drag.get() + spin.get())
const reduce = useReducedMotionConfig()
const visible = useInView(ref)
const dragging = useRef<Drag | null>(null)
const [front, setFront] = useState(0)
useAnimationFrame((_, delta) => {
if (reduce || !visible || dragging.current) return
spin.set(spin.get() - delta * 0.006)
})
useMotionValueEvent(rotation, "change", (value) => {
setFront((((Math.round(-value / step) % count) + count) % count))
})
function onDown(event: PointerEvent<HTMLDivElement>) {
event.currentTarget.setPointerCapture(event.pointerId)
dragging.current = { startX: event.clientX, startValue: drag.get(), lastX: event.clientX, lastTime: event.timeStamp, velocity: 0 }
}
function onMove(event: PointerEvent<HTMLDivElement>) {
const state = dragging.current
if (!state) return
state.velocity = (event.clientX - state.lastX) / Math.max(1, event.timeStamp - state.lastTime)
state.lastX = event.clientX
state.lastTime = event.timeStamp
drag.set(state.startValue + (event.clientX - state.startX) * 0.3)
}
function onUp() {
const state = dragging.current
if (!state) return
dragging.current = null
// Schwung: bei schnellem Loslassen dreht es noch ein Stück weiter
animate(drag, drag.get() + state.velocity * 240, { duration: 1, ease: [0.16, 1, 0.3, 1] })
}
return (
<section ref={ref} className="overflow-hidden py-24 md:py-36">
<div className={cn(container, "grid gap-6 md:grid-cols-12 md:items-end")}>
<div className="md:col-span-7">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-5" />
</div>
{data.text.trim() && <p className="text-t-muted md:col-span-4 md:col-start-9 md:text-right">{data.text}</p>}
</div>
<div
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
style={{ "--n": count, "--r": "calc(var(--w) / 2 / tan(calc(180deg / var(--n))) * 1.28)" } as CSSProperties}
className="relative mt-8 h-[min(80vw,660px)] min-h-[360px] cursor-grab touch-pan-y select-none [--w:clamp(140px,38vw,280px)] [perspective:1700px] active:cursor-grabbing md:mt-12 md:h-[min(46vw,660px)] md:[--w:clamp(150px,18vw,280px)]"
>
{/* Leicht von oben betrachtet, damit der Ring als Ring lesbar ist */}
<div className="absolute inset-0 [transform:rotateX(-7deg)] [transform-style:preserve-3d]">
<motion.div style={{ rotateY: rotation }} className="absolute inset-0 [transform-style:preserve-3d]">
{images.map((image, i) => (
<figure
key={i}
className="absolute top-1/2 left-1/2 aspect-[3/4] w-[var(--w)] [transform-style:preserve-3d]"
style={{ transform: `translate(-50%, -50%) rotateY(${i * step}deg) translateZ(var(--r))` }}
>
<div className="absolute inset-0 overflow-hidden rounded-[1rem] bg-t-surface shadow-[0_30px_60px_-30px_rgba(0,0,0,0.55)] [backface-visibility:hidden]">
<TImage src={image.src} alt={image.caption} sizes="300px" />
</div>
{/* Rückseite: dasselbe Bild gedimmt – hinten entsteht so ein geschlossener Ring statt einer Lücke */}
<div aria-hidden className="absolute inset-0 overflow-hidden rounded-[1rem] bg-t-surface [transform:rotateY(180deg)] [backface-visibility:hidden]">
<TImage src={image.src} sizes="300px" className="opacity-30 grayscale" />
</div>
</figure>
))}
</motion.div>
</div>
</div>
<div className="mt-6 h-6 overflow-hidden text-center text-sm tracking-[0.18em] text-t-muted uppercase md:mt-8">
<AnimatePresence mode="wait" initial={false}>
<motion.p key={front} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
{images[front]?.caption}
</motion.p>
</AnimatePresence>
</div>
</section>
)
}