mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
261 lines
15 KiB
TypeScript
261 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { motion, useMotionTemplate, useMotionValue, useSpring } from "motion/react"
|
|
import { useRef, useState, type PointerEvent, type ReactNode } from "react"
|
|
import { ArrowUpRightIcon } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
import { splitList } from "../shared/hooks"
|
|
import { TImage } from "../shared/media"
|
|
import { Reveal, ease } from "../shared/motion"
|
|
import type { LeistungenBentoData, LeistungenKartenData, LeistungenPanelsData } from "./definitions"
|
|
import type { BlockProps } from "./types"
|
|
import { container, Eyebrow, Heading } from "./ui"
|
|
|
|
const number = (i: number) => String(i + 1).padStart(2, "0")
|
|
|
|
// ── Panels ───────────────────────────────────────────────────────────────────
|
|
|
|
// Bildpaneele nebeneinander; das aktive zieht sich auf (Desktop). Mobil als Karten untereinander.
|
|
export function LeistungenPanels({ data }: BlockProps<LeistungenPanelsData>) {
|
|
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="max-w-3xl">
|
|
<Eyebrow>{data.eyebrow}</Eyebrow>
|
|
<Heading text={data.title} className="mt-6" />
|
|
</div>
|
|
|
|
<div className="mt-12 hidden h-[72vh] max-h-[820px] min-h-[520px] gap-3 md:flex">
|
|
{data.items.map((item, i) => {
|
|
const open = i === current
|
|
return (
|
|
<article
|
|
key={i}
|
|
tabIndex={0}
|
|
onPointerEnter={() => setActive(i)}
|
|
onFocus={() => setActive(i)}
|
|
onClick={() => setActive(i)}
|
|
style={{ flexBasis: 0 }}
|
|
className={cn(
|
|
"relative min-w-0 cursor-pointer overflow-hidden rounded-[1.25rem] bg-black text-white outline-none focus-visible:ring-2 focus-visible:ring-t-accent",
|
|
"transition-[flex-grow] duration-700 ease-[cubic-bezier(0.77,0,0.175,1)]",
|
|
open ? "grow-[5]" : "grow"
|
|
)}
|
|
>
|
|
<TImage
|
|
src={item.image}
|
|
alt={item.title}
|
|
sizes="60vw"
|
|
className={cn("transition-transform duration-[1.4s] ease-out", open ? "scale-100" : "scale-110")}
|
|
/>
|
|
<div aria-hidden className={cn("absolute inset-0 bg-black transition-opacity duration-700", open ? "opacity-20" : "opacity-55")} />
|
|
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
|
<span className="absolute top-6 left-6 text-xs tracking-[0.2em] tabular-nums opacity-80">{number(i)}</span>
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"absolute bottom-6 left-6 rotate-180 font-t-display text-2xl whitespace-nowrap transition-opacity duration-300 [writing-mode:vertical-rl]",
|
|
open ? "opacity-0" : "opacity-100 delay-300"
|
|
)}
|
|
>
|
|
{item.title}
|
|
</span>
|
|
<div
|
|
className={cn(
|
|
"absolute inset-x-0 bottom-0 p-8 transition-[opacity,translate] duration-500 lg:p-10",
|
|
open ? "translate-y-0 opacity-100 delay-300" : "pointer-events-none translate-y-6 opacity-0"
|
|
)}
|
|
>
|
|
<h3 className="font-t-display text-[clamp(2rem,3.4vw,3.5rem)] leading-none tracking-[-0.03em]">{item.title}</h3>
|
|
<p className="mt-4 max-w-md text-white/80">{item.text}</p>
|
|
</div>
|
|
</article>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="mt-10 grid gap-3 md:hidden">
|
|
{data.items.map((item, i) => (
|
|
<Reveal key={i}>
|
|
<article className="relative aspect-[4/5] overflow-hidden rounded-[1.25rem] bg-black text-white">
|
|
<TImage src={item.image} alt={item.title} sizes="100vw" />
|
|
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
|
|
<div className="absolute inset-x-0 bottom-0 p-6">
|
|
<span className="text-xs tracking-[0.2em] opacity-80">{number(i)}</span>
|
|
<h3 className="mt-2 font-t-display text-3xl tracking-[-0.02em]">{item.title}</h3>
|
|
<p className="mt-2 text-white/80">{item.text}</p>
|
|
</div>
|
|
</article>
|
|
</Reveal>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── Bento ────────────────────────────────────────────────────────────────────
|
|
|
|
// Ein Lichtkegel folgt der Maus über alle Kacheln: Rand und Fläche leuchten in Akzentfarbe auf.
|
|
// Die Position steht als --x/--y direkt an jeder Kachel (nicht am Raster), damit nur die Kacheln neu zeichnen.
|
|
export function LeistungenBento({ data }: BlockProps<LeistungenBentoData>) {
|
|
const cards = useRef<(HTMLDivElement | null)[]>([])
|
|
|
|
function onMove(event: PointerEvent<HTMLDivElement>) {
|
|
for (const card of cards.current) {
|
|
if (!card) continue
|
|
const rect = card.getBoundingClientRect()
|
|
card.style.setProperty("--x", `${event.clientX - rect.left}px`)
|
|
card.style.setProperty("--y", `${event.clientY - rect.top}px`)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className={cn(container, "py-24 md:py-36")}>
|
|
<div className="grid gap-6 md:grid-cols-12 md:items-end md:gap-6">
|
|
<div className="md:col-span-7">
|
|
<Eyebrow>{data.eyebrow}</Eyebrow>
|
|
<Heading text={data.title} className="mt-6" />
|
|
</div>
|
|
{data.text.trim() && (
|
|
<Reveal className="md:col-span-4 md:col-start-9">
|
|
<p className="text-lg leading-relaxed text-t-muted">{data.text}</p>
|
|
</Reveal>
|
|
)}
|
|
</div>
|
|
|
|
<div onPointerMove={onMove} className="group/grid mt-12 grid gap-3 md:mt-16 md:auto-rows-[minmax(15rem,auto)] md:grid-cols-3">
|
|
{data.items.map((item, i) => {
|
|
const featured = i === 0
|
|
return (
|
|
<motion.div
|
|
key={i}
|
|
ref={(node) => {
|
|
cards.current[i] = node
|
|
}}
|
|
initial={{ opacity: 0, y: 24 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
|
transition={{ duration: 0.8, ease, delay: (i % 3) * 0.08 }}
|
|
className={cn(
|
|
"group/card relative rounded-[1.5rem] bg-t-fg/10 p-px",
|
|
"[background-image:radial-gradient(380px_circle_at_var(--x,-999px)_var(--y,-999px),var(--t-accent),transparent_45%)]",
|
|
featured && "md:col-span-2 md:row-span-2"
|
|
)}
|
|
>
|
|
<div className="relative flex h-full flex-col overflow-hidden rounded-[calc(1.5rem-1px)] bg-t-bg p-7 md:p-8">
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover/grid:opacity-100 [background:radial-gradient(520px_circle_at_var(--x,-999px)_var(--y,-999px),color-mix(in_oklab,var(--t-accent)_14%,transparent),transparent_45%)]"
|
|
/>
|
|
{item.image.trim() && (
|
|
<div
|
|
className={cn(
|
|
"relative mb-7 overflow-hidden rounded-[1rem] bg-t-surface",
|
|
featured ? "aspect-[16/9] md:aspect-auto md:min-h-[14rem] md:flex-1" : "aspect-[16/10]"
|
|
)}
|
|
>
|
|
<TImage
|
|
src={item.image}
|
|
alt={item.title}
|
|
sizes={featured ? "(min-width: 768px) 60vw, 100vw" : "(min-width: 768px) 30vw, 100vw"}
|
|
className="transition-transform duration-700 ease-out group-hover/card:scale-[1.04]"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="relative mt-auto">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-mono text-xs text-t-muted">{number(i)}</span>
|
|
<ArrowUpRightIcon className="size-4 text-t-muted transition-transform duration-300 group-hover/card:rotate-45 group-hover/card:text-t-accent" />
|
|
</div>
|
|
<h3 className={cn("mt-3 font-t-display tracking-[-0.02em]", featured ? "text-3xl md:text-5xl" : "text-2xl")}>{item.title}</h3>
|
|
<p className="mt-3 max-w-md leading-relaxed text-t-muted">{item.text}</p>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── 3D-Karten ────────────────────────────────────────────────────────────────
|
|
|
|
// Neigt sich zur Maus und wirft einen Glanz (nur mit Maus; auf Touch bleibt die Karte ruhig)
|
|
function TiltCard({ children }: { children: ReactNode }) {
|
|
const rotateX = useMotionValue(0)
|
|
const rotateY = useMotionValue(0)
|
|
const glareX = useMotionValue(50)
|
|
const glareY = useMotionValue(50)
|
|
const springX = useSpring(rotateX, { stiffness: 180, damping: 18 })
|
|
const springY = useSpring(rotateY, { stiffness: 180, damping: 18 })
|
|
const glare = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, color-mix(in oklab, var(--t-fg) 14%, transparent), transparent 55%)`
|
|
|
|
function onMove(event: PointerEvent<HTMLDivElement>) {
|
|
if (event.pointerType !== "mouse") return
|
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
const px = (event.clientX - rect.left) / rect.width
|
|
const py = (event.clientY - rect.top) / rect.height
|
|
rotateY.set((px - 0.5) * 12)
|
|
rotateX.set((0.5 - py) * 12)
|
|
glareX.set(px * 100)
|
|
glareY.set(py * 100)
|
|
}
|
|
|
|
return (
|
|
<div className="h-full [perspective:1200px]">
|
|
<motion.div
|
|
onPointerMove={onMove}
|
|
onPointerLeave={() => {
|
|
rotateX.set(0)
|
|
rotateY.set(0)
|
|
}}
|
|
style={{ rotateX: springX, rotateY: springY }}
|
|
className="group relative h-full rounded-[1.75rem] border border-t-fg/10 bg-t-surface p-8 md:p-10"
|
|
>
|
|
<motion.div aria-hidden style={{ background: glare }} className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
|
<div className="relative">{children}</div>
|
|
</motion.div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function LeistungenKarten({ data }: BlockProps<LeistungenKartenData>) {
|
|
return (
|
|
<section className={cn(container, "py-24 md:py-36")}>
|
|
<div className="max-w-3xl">
|
|
<Eyebrow>{data.eyebrow}</Eyebrow>
|
|
<Heading text={data.title} className="mt-6" />
|
|
</div>
|
|
<div className="mt-12 grid gap-4 md:mt-16 md:grid-cols-2">
|
|
{data.items.map((item, i) => {
|
|
const features = splitList(item.features, "\n")
|
|
return (
|
|
<Reveal key={i} delay={(i % 2) * 0.08} className="h-full">
|
|
<TiltCard>
|
|
<div className="flex items-start justify-between gap-6">
|
|
<span className="font-t-display text-[5.5rem] leading-[0.8] tracking-[-0.05em] text-t-accent">{number(i)}</span>
|
|
<ArrowUpRightIcon className="size-6 text-t-muted transition-transform duration-500 group-hover:rotate-45" />
|
|
</div>
|
|
<h3 className="mt-10 font-t-display text-3xl tracking-[-0.02em] md:text-4xl">{item.title}</h3>
|
|
<p className="mt-3 max-w-sm text-t-muted">{item.text}</p>
|
|
{features.length > 0 && (
|
|
<ul className="mt-8 grid gap-2.5 border-t border-t-fg/10 pt-6 text-sm">
|
|
{features.map((feature, j) => (
|
|
<li key={j} className="flex items-center gap-3">
|
|
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-t-accent" />
|
|
{feature}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</TiltCard>
|
|
</Reveal>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|