9b6638eb00
Deploy / deploy (push) Successful in 1m13s
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>
238 lines
9.3 KiB
TypeScript
238 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Stagger, StaggerItem } from "../ui/Stagger";
|
|
import { EASE_OUT_EXPO } from "@/lib/motion";
|
|
import type { Screenshot } from "@/lib/games";
|
|
|
|
type Props = {
|
|
screenshots: Screenshot[];
|
|
/** Marks the captures as work in progress. */
|
|
wip?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Screenshot grid with a lightbox.
|
|
*
|
|
* Captions live under the thumbnail and on a solid bar inside the lightbox —
|
|
* never floating over the image, which is far too bright to carry text.
|
|
*/
|
|
/**
|
|
* Which way the next capture slides in. Stored rather than derived: after the
|
|
* index has changed there is no way to tell whether it went up or wrapped
|
|
* around from the last shot to the first.
|
|
*/
|
|
const slide = {
|
|
enter: (direction: number) => ({ opacity: 0, x: direction * 48 }),
|
|
center: { opacity: 1, x: 0 },
|
|
exit: (direction: number) => ({ opacity: 0, x: direction * -48 }),
|
|
};
|
|
|
|
export function ScreenshotGallery({ screenshots, wip = true }: Props) {
|
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
|
const [direction, setDirection] = useState(1);
|
|
const triggersRef = useRef<(HTMLButtonElement | null)[]>([]);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
|
|
const close = useCallback(() => {
|
|
setOpenIndex((current) => {
|
|
if (current !== null) triggersRef.current[current]?.focus();
|
|
return null;
|
|
});
|
|
}, []);
|
|
|
|
const step = useCallback(
|
|
(delta: number) => {
|
|
setDirection(delta);
|
|
setOpenIndex((current) =>
|
|
current === null
|
|
? current
|
|
: (current + delta + screenshots.length) % screenshots.length,
|
|
);
|
|
},
|
|
[screenshots.length],
|
|
);
|
|
|
|
// Keyboard controls + scroll lock while the lightbox is open.
|
|
useEffect(() => {
|
|
if (openIndex === null) return;
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") close();
|
|
else if (event.key === "ArrowRight") step(1);
|
|
else if (event.key === "ArrowLeft") step(-1);
|
|
};
|
|
|
|
document.addEventListener("keydown", onKeyDown);
|
|
const previousOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
dialogRef.current?.focus();
|
|
|
|
return () => {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
document.body.style.overflow = previousOverflow;
|
|
};
|
|
}, [openIndex, close, step]);
|
|
|
|
if (screenshots.length === 0) return null;
|
|
|
|
const active = openIndex === null ? null : screenshots[openIndex];
|
|
|
|
return (
|
|
<>
|
|
<Stagger as="ul" step={0.06} className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{screenshots.map((shot, index) => (
|
|
<StaggerItem as="li" key={shot.src}>
|
|
<button
|
|
type="button"
|
|
ref={(node) => {
|
|
triggersRef.current[index] = node;
|
|
}}
|
|
onClick={() => {
|
|
setDirection(1);
|
|
setOpenIndex(index);
|
|
}}
|
|
className="group block w-full text-left transition-transform duration-200 ease-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
|
>
|
|
<span className="relative block aspect-video overflow-hidden rounded-2xl border border-border bg-surface-elevated transition-colors duration-300 group-hover:border-accent/60">
|
|
<Image
|
|
src={shot.src}
|
|
alt={shot.caption}
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
|
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
|
|
/>
|
|
{wip && (
|
|
<span className="absolute left-3 top-3 bg-background/90 px-2.5 py-1 font-display text-[0.65rem] tracking-[0.2em] text-accent">
|
|
WIP
|
|
</span>
|
|
)}
|
|
{/* The label rises into place rather than appearing, so the
|
|
overlay reads as one gesture instead of a flash. */}
|
|
<span
|
|
aria-hidden
|
|
className="absolute inset-0 flex items-center justify-center bg-background/50 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
|
>
|
|
<span className="translate-y-2 border border-accent px-4 py-2 font-display text-xs tracking-[0.25em] text-accent transition-transform duration-300 ease-out group-hover:translate-y-0">
|
|
VIEW
|
|
</span>
|
|
</span>
|
|
</span>
|
|
<span className="mt-3 block text-sm text-foreground-muted transition-colors duration-300 group-hover:text-foreground">
|
|
{shot.caption}
|
|
</span>
|
|
</button>
|
|
</StaggerItem>
|
|
))}
|
|
</Stagger>
|
|
|
|
<AnimatePresence>
|
|
{active && (
|
|
<motion.div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={active.caption}
|
|
tabIndex={-1}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
onClick={close}
|
|
className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-background/95 p-4 backdrop-blur-sm sm:p-8"
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.98, y: 4 }}
|
|
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
|
className="relative w-full max-w-6xl"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="relative aspect-video w-full overflow-hidden rounded-t-2xl border border-border bg-surface-elevated">
|
|
{/* Each capture slides in from the side you asked for. Without
|
|
it, stepping through a gallery of similar screenshots gives
|
|
no sense of having moved at all. */}
|
|
<AnimatePresence initial={false} custom={direction}>
|
|
<motion.div
|
|
key={active.src}
|
|
custom={direction}
|
|
variants={slide}
|
|
initial="enter"
|
|
animate="center"
|
|
exit="exit"
|
|
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
|
className="absolute inset-0"
|
|
>
|
|
<Image
|
|
src={active.src}
|
|
alt={active.caption}
|
|
fill
|
|
sizes="100vw"
|
|
className="object-contain"
|
|
priority
|
|
/>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4 rounded-b-2xl border border-t-0 border-border bg-surface px-4 py-3">
|
|
<p className="text-sm text-foreground-muted">
|
|
{active.caption}
|
|
</p>
|
|
<p className="shrink-0 font-display text-xs tracking-[0.25em] text-foreground-muted">
|
|
{(openIndex ?? 0) + 1} / {screenshots.length}
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{screenshots.length > 1 && (
|
|
<div
|
|
className="flex items-center gap-3"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => step(-1)}
|
|
aria-label="Previous screenshot"
|
|
className="group border border-border px-5 py-2 font-display text-sm tracking-widest text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95"
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
|
|
>
|
|
←
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => step(1)}
|
|
aria-label="Next screenshot"
|
|
className="group border border-border px-5 py-2 font-display text-sm tracking-widest text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95"
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
|
>
|
|
→
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={close}
|
|
aria-label="Close gallery"
|
|
className="absolute right-4 top-4 border border-border bg-background/80 px-4 py-2 font-display text-xs tracking-[0.25em] text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95 sm:right-8 sm:top-8"
|
|
>
|
|
CLOSE ✕
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|