mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
animate,
|
|
AnimatePresence,
|
|
motion,
|
|
useInView,
|
|
useMotionValue,
|
|
useMotionValueEvent,
|
|
useReducedMotion,
|
|
useScroll,
|
|
useTransform,
|
|
type MotionValue,
|
|
} from "motion/react"
|
|
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"
|
|
import { PlusIcon } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
import { TImage } from "./media"
|
|
|
|
export const ease = [0.22, 1, 0.36, 1] as const
|
|
const inView = { once: true, margin: "0px 0px -10% 0px" } as const
|
|
|
|
type Token = { word: string; em: boolean }
|
|
|
|
// Zeilen per "\n", *Wörter in Sternchen* werden hervorgehoben (kursiv/Akzent, je nach Vorlage)
|
|
export function parseRich(text: string): Token[][] {
|
|
let em = false
|
|
return text.split("\n").map((line) =>
|
|
line.split(" ").filter(Boolean).map((raw) => {
|
|
let word = raw
|
|
const opens = word.startsWith("*")
|
|
if (opens) word = word.slice(1)
|
|
// Schließendes Sternchen auch vor Satzzeichen erkennen: "*Herz*," → "Herz,"
|
|
const closing = word.match(/\*([.,;:!?…"“”)]*)$/)
|
|
const closes = closing !== null
|
|
if (closing) word = word.slice(0, closing.index) + closing[1]
|
|
if (opens) em = true
|
|
const token = { word, em }
|
|
if (closes) em = false
|
|
return token
|
|
})
|
|
)
|
|
}
|
|
|
|
// Navigation: bekommt nach dem ersten Scrollen einen Hintergrund, versteckt sich beim Runterscrollen
|
|
export function useScrollNav() {
|
|
const { scrollY } = useScroll()
|
|
const [state, setState] = useState({ scrolled: false, hidden: false })
|
|
|
|
useMotionValueEvent(scrollY, "change", (y) => {
|
|
const next = { scrolled: y > 40, hidden: y > (scrollY.getPrevious() ?? 0) && y > 240 }
|
|
setState((current) => (current.scrolled === next.scrolled && current.hidden === next.hidden ? current : next))
|
|
})
|
|
|
|
return state
|
|
}
|
|
|
|
// Schriftgröße für einen Schriftzug über die volle Breite, abhängig von der Länge des Namens
|
|
export function wordmarkSize(name: string, max = 26) {
|
|
return `${Math.min(max, 140 / Math.max(name.length, 1)).toFixed(2)}vw`
|
|
}
|
|
|
|
export function Rich({ text, emClassName }: { text: string; emClassName?: string }) {
|
|
return parseRich(text).map((tokens, l) => (
|
|
<Fragment key={l}>
|
|
{l > 0 && <br />}
|
|
{tokens.map((token, i) => (
|
|
<Fragment key={i}>
|
|
{i > 0 && " "}
|
|
{token.em ? <em className={emClassName}>{token.word}</em> : token.word}
|
|
</Fragment>
|
|
))}
|
|
</Fragment>
|
|
))
|
|
}
|
|
|
|
export function Reveal({ children, className, delay = 0, y = 32 }: {
|
|
children: ReactNode
|
|
className?: string
|
|
delay?: number
|
|
y?: number
|
|
}) {
|
|
return (
|
|
<motion.div
|
|
className={className}
|
|
initial={{ opacity: 0, y }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={inView}
|
|
transition={{ duration: 0.9, ease, delay }}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
)
|
|
}
|
|
|
|
const textTags = { h1: motion.h1, h2: motion.h2, h3: motion.h3, p: motion.p }
|
|
|
|
// Wörter gleiten zeilenweise aus einer Maske nach oben
|
|
export function RevealWords({ text, as = "h2", className, emClassName, delay = 0, onMount = false }: {
|
|
text: string
|
|
as?: keyof typeof textTags
|
|
className?: string
|
|
emClassName?: string
|
|
delay?: number
|
|
onMount?: boolean
|
|
}) {
|
|
const Tag = textTags[as]
|
|
const lines = parseRich(text)
|
|
const trigger = onMount ? { animate: "show" } : { whileInView: "show", viewport: inView }
|
|
|
|
return (
|
|
<Tag className={className} initial="hidden" {...trigger}>
|
|
<span className="sr-only">{text.replace(/\*/g, "")}</span>
|
|
{lines.map((tokens, l) => {
|
|
const offset = lines.slice(0, l).reduce((sum, line) => sum + line.length, 0)
|
|
return (
|
|
<span key={l} aria-hidden className="block">
|
|
{tokens.map((token, i) => (
|
|
<Fragment key={i}>
|
|
{i > 0 && " "}
|
|
<span className="mb-[-0.14em] inline-block overflow-hidden pb-[0.14em] align-top">
|
|
<motion.span
|
|
className={cn("inline-block", token.em && emClassName)}
|
|
variants={{
|
|
hidden: { y: "105%" },
|
|
show: { y: "0%", transition: { duration: 1.05, ease, delay: delay + (offset + i) * 0.055 } },
|
|
}}
|
|
>
|
|
{token.word}
|
|
</motion.span>
|
|
</span>
|
|
</Fragment>
|
|
))}
|
|
</span>
|
|
)
|
|
})}
|
|
</Tag>
|
|
)
|
|
}
|
|
|
|
function ScrollWord({ progress, range, children }: { progress: MotionValue<number>; range: [number, number]; children: string }) {
|
|
const opacity = useTransform(progress, range, [0.14, 1])
|
|
return <motion.span style={{ opacity }}>{children}</motion.span>
|
|
}
|
|
|
|
// Wörter leuchten beim Scrollen nacheinander auf
|
|
export function ScrollWords({ text, className }: { text: string; className?: string }) {
|
|
const ref = useRef<HTMLParagraphElement>(null)
|
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.85", "end 0.45"] })
|
|
const words = text.split(/\s+/).filter(Boolean)
|
|
|
|
return (
|
|
<p ref={ref} className={className}>
|
|
{words.map((word, i) => (
|
|
<Fragment key={i}>
|
|
{i > 0 && " "}
|
|
<ScrollWord progress={scrollYProgress} range={[i / words.length, (i + 1) / words.length]}>
|
|
{word}
|
|
</ScrollWord>
|
|
</Fragment>
|
|
))}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
export function ParallaxImage({ src, alt, className, imageClassName, offset = 60, sizes, eager }: {
|
|
src: string
|
|
alt?: string
|
|
className?: string
|
|
imageClassName?: string
|
|
offset?: number
|
|
sizes?: string
|
|
eager?: boolean
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
|
|
const y = useTransform(scrollYProgress, [0, 1], [-offset, offset])
|
|
|
|
return (
|
|
<div ref={ref} className={cn("relative overflow-hidden", className)}>
|
|
<motion.div className="absolute inset-x-0" style={{ y, top: -offset, bottom: -offset }}>
|
|
<TImage src={src} alt={alt} sizes={sizes} eager={eager} className={imageClassName} />
|
|
</motion.div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Endlos-Laufband; jede Kopie enthält den Inhalt doppelt, damit auch breite Bildschirme gefüllt sind
|
|
export function Marquee({ children, className, duration = 40, reverse = false }: {
|
|
children: ReactNode
|
|
className?: string
|
|
duration?: number
|
|
reverse?: boolean
|
|
}) {
|
|
return (
|
|
<div className={cn("flex overflow-hidden", className)}>
|
|
{[0, 1].map((copy) => (
|
|
<motion.div
|
|
key={copy}
|
|
aria-hidden={copy === 1}
|
|
className="flex shrink-0"
|
|
animate={{ x: reverse ? ["-100%", "0%"] : ["0%", "-100%"] }}
|
|
transition={{ duration, ease: "linear", repeat: Infinity }}
|
|
>
|
|
{[0, 1].map((repeat) => (
|
|
<div key={repeat} className="flex shrink-0 items-center">{children}</div>
|
|
))}
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Zählt deutsch formatierte Zahlen ("1.200", "4,9") hoch, sobald sie sichtbar werden
|
|
export function Counter({ value, className }: { value: string; className?: string }) {
|
|
const ref = useRef<HTMLSpanElement>(null)
|
|
const visible = useInView(ref, inView)
|
|
const reduceMotion = useReducedMotion()
|
|
|
|
useEffect(() => {
|
|
const target = parseFloat(value.replace(/\./g, "").replace(",", "."))
|
|
if (!visible || reduceMotion || Number.isNaN(target)) return
|
|
|
|
const decimals = value.split(",")[1]?.length ?? 0
|
|
const format = (n: number) => n.toLocaleString("de-DE", { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
|
const controls = animate(0, target, {
|
|
duration: 1.8,
|
|
ease,
|
|
onUpdate: (n) => {
|
|
if (ref.current) ref.current.textContent = format(n)
|
|
},
|
|
})
|
|
return () => controls.stop()
|
|
}, [visible, reduceMotion, value])
|
|
|
|
return <span ref={ref} className={className}>{value}</span>
|
|
}
|
|
|
|
// Schritt-Wechsel in Formularen mit mehreren Schritten (Buchung, Bewerbung, Quiz)
|
|
export function StepPanel({ step, children, className }: { step: number | string; children: ReactNode; className?: string }) {
|
|
return (
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
<motion.div
|
|
key={step}
|
|
className={className}
|
|
initial={{ opacity: 0, x: 28 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -28 }}
|
|
transition={{ duration: 0.35, ease }}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
)
|
|
}
|
|
|
|
export function Accordion({ items, className, itemClassName, questionClassName, answerClassName }: {
|
|
items: { question: string; answer: string }[]
|
|
className?: string
|
|
itemClassName?: string
|
|
questionClassName?: string
|
|
answerClassName?: string
|
|
}) {
|
|
const [open, setOpen] = useState<number | null>(0)
|
|
|
|
return (
|
|
<div className={className}>
|
|
{items.map((item, i) => (
|
|
<div key={i} className={itemClassName}>
|
|
<button
|
|
type="button"
|
|
aria-expanded={open === i}
|
|
onClick={() => setOpen(open === i ? null : i)}
|
|
className={cn("flex w-full cursor-pointer items-center justify-between gap-6 text-left", questionClassName)}
|
|
>
|
|
<span>{item.question}</span>
|
|
<PlusIcon className={cn("size-5 shrink-0 transition-transform duration-300", open === i && "rotate-45")} />
|
|
</button>
|
|
<AnimatePresence initial={false}>
|
|
{open === i && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.4, ease }}
|
|
className="overflow-hidden"
|
|
>
|
|
<p className={answerClassName}>{item.answer}</p>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Vertikales Scrollen schiebt eine Bildreihe horizontal durch (Sektion bleibt dabei stehen)
|
|
export function HorizontalScroll({ children, header, className, trackClassName }: {
|
|
children: ReactNode
|
|
header?: ReactNode
|
|
className?: string
|
|
trackClassName?: string
|
|
}) {
|
|
const sectionRef = useRef<HTMLElement>(null)
|
|
const trackRef = useRef<HTMLDivElement>(null)
|
|
const distance = useMotionValue(0)
|
|
const { scrollYProgress } = useScroll({ target: sectionRef, offset: ["start start", "end end"] })
|
|
const x = useTransform(() => -scrollYProgress.get() * distance.get())
|
|
|
|
useEffect(() => {
|
|
const section = sectionRef.current
|
|
const track = trackRef.current
|
|
if (!section || !track) return
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
const overflow = Math.max(0, track.scrollWidth - document.documentElement.clientWidth)
|
|
distance.set(overflow)
|
|
section.style.height = `calc(100vh + ${overflow}px)`
|
|
})
|
|
observer.observe(track)
|
|
observer.observe(document.documentElement)
|
|
return () => observer.disconnect()
|
|
}, [distance])
|
|
|
|
return (
|
|
<section ref={sectionRef} className={cn("relative h-screen", className)}>
|
|
<div className="sticky top-0 flex h-screen flex-col justify-center overflow-hidden">
|
|
{header}
|
|
<motion.div ref={trackRef} style={{ x }} className={cn("flex w-max", trackClassName)}>
|
|
{children}
|
|
</motion.div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|