mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
275 lines
14 KiB
TypeScript
275 lines
14 KiB
TypeScript
"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>
|
||
)
|
||
}
|