mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
255 lines
14 KiB
TypeScript
255 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { AnimatePresence, motion, useMotionValue, useSpring } from "motion/react"
|
|
import { useEffect, useRef, useState } from "react"
|
|
import { ArrowUpRightIcon } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
import type { TemplateProps } from "../../lib/types"
|
|
import { TImage } from "../shared/media"
|
|
import { Counter, Marquee, Reveal, RevealWords, ScrollWords, ease } from "../shared/motion"
|
|
import { TemplateShell } from "../shared/shell"
|
|
import { agentur, type AgenturData } from "./definition"
|
|
import { fonts } from "./fonts"
|
|
|
|
const em = "italic text-t-accent"
|
|
const container = "mx-auto w-full max-w-[1500px] px-5 md:px-10"
|
|
const display = "font-t-display font-extrabold tracking-[-0.05em]"
|
|
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ#%&*+"
|
|
|
|
// Buchstaben würfeln kurz durcheinander und rasten dann ein
|
|
function Scramble({ text, className }: { text: string; className?: string }) {
|
|
const [output, setOutput] = useState(text)
|
|
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
useEffect(() => () => {
|
|
if (timer.current) clearInterval(timer.current)
|
|
}, [])
|
|
|
|
function run() {
|
|
if (timer.current) clearInterval(timer.current)
|
|
let frame = 0
|
|
timer.current = setInterval(() => {
|
|
frame += 1
|
|
setOutput(
|
|
text
|
|
.split("")
|
|
.map((char, i) => (char === " " || i < frame / 2 ? char : GLYPHS[(i * 7 + frame * 3) % GLYPHS.length]))
|
|
.join("")
|
|
)
|
|
if (frame / 2 >= text.length && timer.current) clearInterval(timer.current)
|
|
}, 30)
|
|
}
|
|
|
|
return (
|
|
<span onMouseEnter={run} className={className}>
|
|
{output}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export function AgenturTemplate({ content }: TemplateProps<AgenturData>) {
|
|
const { brand, hero, clients, work, manifest, services, numbers, team, contact } = content.data
|
|
const show = (key: string) => !content.hidden.includes(key)
|
|
const [cursor, setCursor] = useState<string | null>(null)
|
|
const x = useMotionValue(-100)
|
|
const y = useMotionValue(-100)
|
|
const springX = useSpring(x, { stiffness: 500, damping: 40 })
|
|
const springY = useSpring(y, { stiffness: 500, damping: 40 })
|
|
|
|
useEffect(() => {
|
|
const move = (event: PointerEvent) => {
|
|
x.set(event.clientX)
|
|
y.set(event.clientY)
|
|
}
|
|
window.addEventListener("pointermove", move)
|
|
return () => window.removeEventListener("pointermove", move)
|
|
}, [x, y])
|
|
|
|
return (
|
|
<TemplateShell def={agentur} content={content} fonts={fonts[content.fonts] ?? fonts.brutal} className="[@media(hover:hover)]:cursor-none [@media(hover:hover)]:[&_a]:cursor-none [@media(hover:hover)]:[&_button]:cursor-none">
|
|
<motion.div
|
|
aria-hidden
|
|
style={{ x: springX, y: springY }}
|
|
className="pointer-events-none fixed top-0 left-0 z-[100] hidden [@media(hover:hover)]:block"
|
|
>
|
|
<motion.div
|
|
animate={{ width: cursor ? 110 : 16, height: cursor ? 110 : 16 }}
|
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
|
className="grid -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-t-accent text-xs font-bold tracking-wider text-t-accent-fg uppercase"
|
|
>
|
|
{cursor}
|
|
</motion.div>
|
|
</motion.div>
|
|
|
|
<header className="fixed inset-x-0 top-0 z-50 mix-blend-difference">
|
|
<nav className={cn(container, "flex h-20 items-center justify-between text-white")}>
|
|
<a href="#top" className={cn(display, "text-2xl")}>{brand.name}</a>
|
|
<div className="hidden gap-8 text-sm font-semibold uppercase md:flex">
|
|
<a href="#arbeiten"><Scramble text="Arbeiten" /></a>
|
|
{show("services") && <a href="#leistungen"><Scramble text="Leistungen" /></a>}
|
|
{show("team") && <a href="#team"><Scramble text="Team" /></a>}
|
|
</div>
|
|
<a href="#kontakt" className="text-sm font-semibold uppercase underline underline-offset-4"><Scramble text={hero.cta} /></a>
|
|
</nav>
|
|
</header>
|
|
|
|
<section id="top" className={cn(container, "relative flex min-h-svh flex-col justify-end pt-32 pb-12")}>
|
|
<motion.div
|
|
initial={{ scale: 0, rotate: -40 }}
|
|
animate={{ scale: 1, rotate: 0 }}
|
|
transition={{ duration: 1.4, ease, delay: 0.4 }}
|
|
className="absolute top-28 right-5 size-40 overflow-hidden rounded-full md:right-10 md:size-72"
|
|
>
|
|
<motion.div className="absolute inset-[-20%]" animate={{ rotate: 360 }} transition={{ duration: 30, repeat: Infinity, ease: "linear" }}>
|
|
<TImage src={hero.image} eager sizes="300px" />
|
|
</motion.div>
|
|
</motion.div>
|
|
<RevealWords as="h1" onMount delay={0.2} text={hero.title} emClassName={em} className={cn(display, "relative text-[clamp(4rem,14vw,15rem)] leading-[0.82]")} />
|
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease, delay: 1 }} className="mt-12 grid gap-8 md:grid-cols-[1fr_auto] md:items-end">
|
|
<p className="max-w-md text-xl leading-snug">{hero.text}</p>
|
|
<p className="text-sm font-semibold uppercase text-t-muted">{brand.tagline} · {brand.city}</p>
|
|
</motion.div>
|
|
</section>
|
|
|
|
{show("clients") && (
|
|
<section data-order="clients" className="border-y border-t-fg/15 py-8">
|
|
<Marquee duration={22}>
|
|
{clients.items.map((client, i) => (
|
|
<span key={i} className={cn(display, "px-10 text-5xl md:text-7xl", i % 2 ? "text-t-fg" : "text-transparent [-webkit-text-stroke:1.5px_var(--t-fg)]")}>
|
|
{client.name}
|
|
</span>
|
|
))}
|
|
</Marquee>
|
|
</section>
|
|
)}
|
|
|
|
<section id="arbeiten" className={cn(container, "py-24 md:py-32")}>
|
|
<RevealWords text={work.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,8rem)] leading-[0.9]")} />
|
|
<div className="mt-16 space-y-6">
|
|
{work.items.map((project, i) => (
|
|
<article
|
|
key={i}
|
|
onMouseEnter={() => setCursor("Ansehen")}
|
|
onMouseLeave={() => setCursor(null)}
|
|
className="sticky overflow-hidden rounded-[2rem] p-6 text-white md:p-10"
|
|
style={{ top: `${6 + i * 1.5}rem`, background: project.color || "var(--t-surface)" }}
|
|
>
|
|
<div className="grid gap-8 md:grid-cols-[1fr_1.3fr] md:items-end">
|
|
<div>
|
|
<p className="text-sm font-semibold uppercase opacity-80">{project.category} · {project.year}</p>
|
|
<h3 className={cn(display, "mt-3 text-[clamp(3rem,7vw,7rem)] leading-[0.9]")}>{project.title}</h3>
|
|
<p className="mt-5 max-w-sm text-lg opacity-90">{project.text}</p>
|
|
<span className="mt-6 inline-flex items-center gap-2 rounded-full bg-white px-5 py-2.5 text-sm font-bold text-zinc-900">
|
|
Case ansehen <ArrowUpRightIcon className="size-4" />
|
|
</span>
|
|
</div>
|
|
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl">
|
|
<TImage src={project.image} alt={project.title} sizes="(min-width: 768px) 55vw, 100vw" />
|
|
</div>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{show("manifest") && (
|
|
<section data-order="manifest" className={cn(container, "py-24 md:py-40")}>
|
|
<ScrollWords text={manifest.text} className={cn(display, "text-[clamp(2.25rem,5.5vw,5.5rem)] leading-[1] tracking-[-0.04em]")} />
|
|
</section>
|
|
)}
|
|
|
|
{show("services") && <Services data-order="services" services={services} />}
|
|
|
|
{show("numbers") && (
|
|
<section data-order="numbers" className="bg-t-accent py-16 text-t-accent-fg">
|
|
<dl className={cn(container, "grid grid-cols-2 gap-8 md:grid-cols-4")}>
|
|
{numbers.items.map((item, i) => (
|
|
<div key={i}>
|
|
<dd className={cn(display, "text-6xl md:text-8xl")}>
|
|
<Counter value={item.value} />
|
|
{item.suffix}
|
|
</dd>
|
|
<dt className="mt-1 font-semibold uppercase">{item.label}</dt>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</section>
|
|
)}
|
|
|
|
{show("team") && (
|
|
<section data-order="team" id="team" className={cn(container, "py-24 md:py-32")}>
|
|
<RevealWords text={team.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,8rem)] leading-[0.9]")} />
|
|
<div className="mt-14 grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
{team.members.map((member, i) => (
|
|
<Reveal key={i} delay={i * 0.08}>
|
|
<div className="group" onMouseEnter={() => setCursor("Hallo!")} onMouseLeave={() => setCursor(null)}>
|
|
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl">
|
|
<TImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="grayscale transition duration-500 group-hover:scale-105 group-hover:grayscale-0" />
|
|
</div>
|
|
<p className={cn(display, "mt-4 text-2xl")}>{member.name}</p>
|
|
<p className="text-sm uppercase text-t-muted">{member.role}</p>
|
|
</div>
|
|
</Reveal>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<section id="kontakt" className={cn(container, "pt-24 pb-16 md:pt-32")}>
|
|
<RevealWords text={contact.title} emClassName={em} className={cn(display, "text-[clamp(3.5rem,10vw,11rem)] leading-[0.85]")} />
|
|
<Reveal delay={0.1}>
|
|
<p className="mt-10 max-w-md text-xl">{contact.text}</p>
|
|
<a
|
|
href={`mailto:${brand.email}`}
|
|
onMouseEnter={() => setCursor("Schreiben")}
|
|
onMouseLeave={() => setCursor(null)}
|
|
className={cn(display, "group relative mt-10 inline-block text-[clamp(2rem,6vw,5.5rem)] text-t-accent")}
|
|
>
|
|
{brand.email}
|
|
<span className="absolute -bottom-1 left-0 h-1.5 w-full origin-left scale-x-0 bg-t-accent transition-transform duration-500 group-hover:scale-x-100" />
|
|
</a>
|
|
</Reveal>
|
|
<div className="mt-24 flex flex-col gap-4 border-t border-t-fg/15 pt-8 text-sm uppercase text-t-muted md:flex-row md:justify-between">
|
|
<p>{brand.name} · {brand.city}</p>
|
|
<div className="flex gap-6">
|
|
<a href="#" className="hover:text-t-fg">Impressum</a>
|
|
<a href="#" className="hover:text-t-fg">Datenschutz</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</TemplateShell>
|
|
)
|
|
}
|
|
|
|
function Services({ services }: { services: AgenturData["services"] }) {
|
|
const [active, setActive] = useState<number | null>(null)
|
|
|
|
return (
|
|
<section id="leistungen" className={cn(container, "py-24 md:py-32")}>
|
|
<RevealWords text={services.title} emClassName={em} className={cn(display, "text-[clamp(3rem,8vw,8rem)] leading-[0.9]")} />
|
|
<ul className="mt-14 border-t border-t-fg/15" onMouseLeave={() => setActive(null)}>
|
|
{services.items.map((item, i) => (
|
|
<li key={i} onMouseEnter={() => setActive(i)} className="border-b border-t-fg/15">
|
|
<div className="flex flex-col gap-2 py-6 md:flex-row md:items-center md:gap-10 md:py-8">
|
|
<span className="text-sm font-semibold text-t-muted">0{i + 1}</span>
|
|
<motion.span
|
|
animate={{ x: active === i ? 24 : 0, opacity: active === null || active === i ? 1 : 0.3 }}
|
|
transition={{ duration: 0.4, ease }}
|
|
className={cn(display, "flex-1 text-[clamp(2.5rem,6vw,6rem)] leading-none")}
|
|
>
|
|
{item.title}
|
|
</motion.span>
|
|
<AnimatePresence>
|
|
{(active === i || active === null) && (
|
|
<motion.p initial={{ opacity: 0 }} animate={{ opacity: active === i ? 1 : 0.6 }} exit={{ opacity: 0 }} className="max-w-xs text-t-muted md:text-right">
|
|
{item.text}
|
|
</motion.p>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)
|
|
}
|