Deux courbes partagées dans lib/motion.ts, pour que le CSS et Framer aient le même feeling, et quatre primitives que Tailwind ne sait pas exprimer : reflet de bouton, halo qui suit le curseur, soulignement qui se dessine, secousse d'erreur. Nouveaux composants : Stagger (les collections se distribuent une par une), Spotlight (trouve son parent tout seul, donc les cartes restent des composants serveur), ScrollProgress, BackToTop, Parallax, Spinner, CheckMark. Reveal accepte maintenant une direction. Côté rendu : le trait de nav glisse d'un onglet à l'autre, les cartes se soulèvent, le hero réagit au scroll, les piliers entrent en zig-zag, la lightbox glisse dans le sens demandé et les formulaires répondent. prefers-reduced-motion est respecté partout : Framer coupe via useReducedMotion, le CSS via la media query déjà en place. Le commit embarque aussi la réorganisation des composants par domaine qui était en cours dans l'arbre de travail — les deux étaient trop imbriquées pour être séparées proprement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { SocialLinks } from "./SocialLinks";
|
||||
import {
|
||||
BRAND,
|
||||
CONTACT_CHANNELS,
|
||||
LEGAL_LINKS,
|
||||
NAV_LINKS,
|
||||
STUDIO,
|
||||
} from "@/lib/site";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-auto border-t border-border">
|
||||
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 md:grid-cols-4 lg:px-10">
|
||||
<div className="md:col-span-2">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={`${STUDIO.name} home`}
|
||||
className="group inline-block"
|
||||
>
|
||||
<Image
|
||||
src={BRAND.wordmark.src}
|
||||
alt={STUDIO.name}
|
||||
width={220}
|
||||
height={Math.round(
|
||||
(220 * BRAND.wordmark.height) / BRAND.wordmark.width,
|
||||
)}
|
||||
className="transition duration-300 ease-out group-hover:-translate-y-0.5 group-hover:opacity-80"
|
||||
/>
|
||||
</Link>
|
||||
<p className="mt-6 max-w-sm text-sm leading-relaxed text-foreground-muted">
|
||||
{STUDIO.tagline} An indie studio crafting survival adventures.
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${CONTACT_CHANNELS[0].email}`}
|
||||
className="underline-grow mt-4 inline-block break-all text-sm text-foreground-muted transition-colors hover:text-accent"
|
||||
>
|
||||
{CONTACT_CHANNELS[0].email}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Footer">
|
||||
<h2 className="mb-5 font-display text-sm tracking-[0.25em] text-foreground-muted">
|
||||
EXPLORE
|
||||
</h2>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="group relative inline-flex items-center gap-1.5 text-foreground-muted transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
<span
|
||||
aria-hidden
|
||||
className="-ml-1 text-xs opacity-0 transition-all duration-300 ease-out group-hover:ml-0 group-hover:opacity-100"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -bottom-0.5 left-0 h-px w-0 bg-accent transition-all duration-300 ease-out group-hover:w-full"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-5 font-display text-sm tracking-[0.25em] text-foreground-muted">
|
||||
FOLLOW
|
||||
</h2>
|
||||
<SocialLinks />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-6 py-6 text-xs text-foreground-muted sm:flex-row lg:px-10">
|
||||
<p>
|
||||
© {new Date().getFullYear()} {STUDIO.name}. All rights reserved.
|
||||
</p>
|
||||
<ul className="flex items-center gap-6">
|
||||
{LEGAL_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="underline-grow inline-block transition-colors hover:text-accent"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="hidden font-display tracking-[0.25em] lg:block">
|
||||
WHERE SUMMITS BECOME WORLDS
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Logo } from "../ui/Logo";
|
||||
import { ScrollProgress } from "../ui/ScrollProgress";
|
||||
import { NAV_LINKS, STUDIO } from "@/lib/site";
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
// The drawer stores the route it was opened on, so any navigation closes it
|
||||
// by derivation — no effect syncing state to the router.
|
||||
const [openedOn, setOpenedOn] = useState<string | null>(null);
|
||||
const open = openedOn === pathname;
|
||||
const close = () => setOpenedOn(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Lock the page and allow Escape to dismiss while the drawer is open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpenedOn(null);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
|
||||
scrolled || open
|
||||
? "border-b border-border bg-background/85 backdrop-blur-md"
|
||||
: "border-b border-transparent bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<nav className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 lg:h-20 lg:px-10">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={`${STUDIO.name} home`}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<Logo
|
||||
width={44}
|
||||
priority
|
||||
className="transition-transform duration-300 ease-out group-hover:-translate-y-0.5 group-hover:scale-105"
|
||||
/>
|
||||
<span className="hidden font-display text-xl tracking-wider transition-colors group-hover:text-accent sm:block">
|
||||
{STUDIO.shortName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<ul className="hidden items-center gap-1 md:flex">
|
||||
{NAV_LINKS.map((link) => {
|
||||
const active = isActive(link.href);
|
||||
|
||||
return (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`group/nav relative px-3 py-2 text-sm tracking-wide transition-colors ${
|
||||
active
|
||||
? "text-accent"
|
||||
: "text-foreground-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
|
||||
{/* Hover hairline for the pages you are not on. Sits under
|
||||
the active marker, so the two never stack visibly. */}
|
||||
{!active && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-foreground-muted/60 transition-transform duration-300 ease-out group-hover/nav:scale-x-100"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* One shared element for every link: Framer moves it from
|
||||
the old tab to the new one instead of cross-fading two
|
||||
separate underlines. */}
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="nav-active"
|
||||
aria-hidden
|
||||
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||
className="absolute inset-x-3 -bottom-0.5 h-px bg-accent"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setOpenedOn(open ? null : pathname)}
|
||||
className="relative flex h-10 w-10 items-center justify-center text-foreground transition-transform duration-200 hover:text-accent active:scale-90 md:hidden"
|
||||
>
|
||||
<span className="relative flex h-4 w-6 flex-col justify-between">
|
||||
<motion.span
|
||||
animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="block h-px origin-center bg-current"
|
||||
/>
|
||||
<motion.span
|
||||
animate={{ opacity: open ? 0 : 1 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="block h-px bg-current"
|
||||
/>
|
||||
<motion.span
|
||||
animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="block h-px origin-center bg-current"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
id="mobile-menu"
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.25, ease: EASE_OUT_EXPO }}
|
||||
className="border-t border-border bg-background/95 backdrop-blur-md md:hidden"
|
||||
>
|
||||
{/* The rows deal out one by one. The drawer is the only place on
|
||||
the site where a whole navigation appears at once, and a block
|
||||
of four identical lines arriving together reads as a flash. */}
|
||||
<motion.ul
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.05 } },
|
||||
}}
|
||||
className="flex flex-col px-6 py-4"
|
||||
>
|
||||
{NAV_LINKS.map((link) => (
|
||||
<motion.li
|
||||
key={link.href}
|
||||
variants={{
|
||||
hidden: { opacity: 0, x: -12 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.3, ease: EASE_OUT_EXPO },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={close}
|
||||
aria-current={isActive(link.href) ? "page" : undefined}
|
||||
className={`group flex items-center justify-between border-b border-border py-4 font-display text-2xl tracking-wider transition-colors last:border-b-0 ${
|
||||
isActive(link.href)
|
||||
? "text-accent"
|
||||
: "text-foreground hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{link.label.toUpperCase()}
|
||||
<span
|
||||
aria-hidden
|
||||
className="text-base opacity-0 transition-all duration-300 ease-out group-hover:translate-x-1 group-hover:opacity-100"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ScrollProgress />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SOCIAL_LINKS } from "@/lib/site";
|
||||
|
||||
const icons: Record<string, ReactNode> = {
|
||||
x: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
),
|
||||
discord: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
),
|
||||
youtube: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136C4.495 20.455 12 20.455 12 20.455s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
),
|
||||
steam: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Every channel is listed, whether or not the account exists yet: the studio
|
||||
* wants visitors to know where it will be. Accounts without a URL render as
|
||||
* plain text marked "soon" rather than as a link to `#`, which would look
|
||||
* functional and go nowhere. Fill in `href` in `lib/site.ts` and the entry
|
||||
* becomes a real link with no change here.
|
||||
*/
|
||||
export function SocialLinks() {
|
||||
return (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{SOCIAL_LINKS.map((social) => {
|
||||
const icon = (
|
||||
<span className="block h-4 w-4 shrink-0 transition duration-300 ease-out group-hover:scale-110 group-hover:-rotate-6">
|
||||
{icons[social.key]}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={social.key}>
|
||||
{social.href ? (
|
||||
<a
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group inline-flex items-center gap-3 text-foreground-muted transition-all duration-300 ease-out hover:translate-x-1 hover:text-foreground [&>span:first-child]:hover:text-accent"
|
||||
>
|
||||
{icon}
|
||||
{social.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-3 text-foreground-muted/55">
|
||||
{icon}
|
||||
{social.label}
|
||||
<span className="text-[0.6rem] uppercase tracking-[0.2em]">
|
||||
Soon
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user