Files
Mathew 9b6638eb00
Deploy / deploy (push) Successful in 1m13s
Ajoute une couche de micro-animations sur tout le site
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>
2026-08-02 16:04:41 +02:00

339 lines
12 KiB
TypeScript

import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import { Newsletter } from "../../components/forms/Newsletter";
import { getAdjacentPosts, getAllPosts, getPost } from "@/lib/devblog";
import { formatDate, readingTime } from "@/lib/format";
import { games } from "@/lib/games";
import { SITE_URL, LOGO_URL, STUDIO } from "@/lib/site";
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return getAllPosts().map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return {};
const url = `${SITE_URL}/devblog/${post.slug}`;
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: url },
openGraph: {
type: "article",
title: post.title,
description: post.excerpt,
url,
publishedTime: post.date,
authors: post.author ? [post.author] : undefined,
tags: post.tags,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}
const mdxComponents = {
// The page title is already the document's h1. A `#` heading inside a post
// renders as an h2 so there is never a second one.
h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2
{...props}
className="mt-12 mb-6 font-display text-3xl tracking-wider first:mt-0 sm:text-4xl"
/>
),
h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 {...props} className="mt-12 mb-4 font-display text-3xl tracking-wider" />
),
h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 {...props} className="mt-8 mb-3 font-display text-2xl tracking-wider" />
),
h4: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4
{...props}
className="mt-8 mb-3 font-display text-xl tracking-wider text-foreground"
/>
),
p: (props: React.HTMLAttributes<HTMLParagraphElement>) => (
<p {...props} className="text-foreground-muted leading-relaxed mb-5" />
),
ul: (props: React.HTMLAttributes<HTMLUListElement>) => (
<ul
{...props}
className="text-foreground-muted leading-relaxed mb-5 ml-6 list-disc space-y-2"
/>
),
ol: (props: React.HTMLAttributes<HTMLOListElement>) => (
<ol
{...props}
className="text-foreground-muted leading-relaxed mb-5 ml-6 list-decimal space-y-2"
/>
),
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a
{...props}
className="text-accent hover:text-foreground underline underline-offset-4 transition-colors"
/>
),
strong: (props: React.HTMLAttributes<HTMLElement>) => (
<strong {...props} className="text-foreground font-semibold" />
),
blockquote: (props: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote
{...props}
className="border-l-2 border-accent pl-6 my-6 italic text-foreground"
/>
),
// Inline code gets a pill; code inside a fence must not, or every block
// paints a bordered chip on top of its own container. Fenced code is the
// case that carries a `language-*` class.
code: (props: React.HTMLAttributes<HTMLElement>) => {
const fenced =
typeof props.className === "string" &&
props.className.includes("language-");
return fenced ? (
<code {...props} className="font-mono text-sm text-foreground" />
) : (
<code
{...props}
className="rounded border border-border bg-surface-elevated px-1.5 py-0.5 text-sm text-accent"
/>
);
},
pre: (props: React.HTMLAttributes<HTMLPreElement>) => (
<pre
{...props}
className="my-6 overflow-x-auto rounded-2xl border border-border bg-surface-elevated p-5 text-sm"
/>
),
// `remarkGfm` is enabled, so a post can contain a table. Without these it
// rendered as unstyled browser-default HTML.
table: (props: React.HTMLAttributes<HTMLTableElement>) => (
<div className="my-6 overflow-x-auto">
<table
{...props}
className="w-full border-collapse text-sm text-foreground-muted"
/>
</div>
),
th: (props: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th
{...props}
className="border border-border bg-surface px-4 py-2 text-left font-display text-xs tracking-[0.2em] text-foreground"
/>
),
td: (props: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td {...props} className="border border-border px-4 py-2 align-top" />
),
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
// Dimensions are unknown at author time, so this stays a plain <img>
// rather than next/image. Lazy by default; it is always below the fold.
// eslint-disable-next-line @next/next/no-img-element
<img
{...props}
alt={props.alt ?? ""}
loading="lazy"
className="my-8 w-full rounded-2xl border border-border"
/>
),
hr: () => <hr className="my-12 border-border" />,
};
export default async function DevblogPost({ params }: Props) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
const { newer, older } = getAdjacentPosts(slug);
const relatedGame = post.game
? games.find((entry) => entry.slug === post.game)
: undefined;
const minutes = readingTime(post.content);
const url = `${SITE_URL}/devblog/${post.slug}`;
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.date,
author: {
"@type": "Organization",
name: post.author ?? STUDIO.name,
},
publisher: {
"@type": "Organization",
name: STUDIO.name,
logo: {
"@type": "ImageObject",
url: LOGO_URL,
},
},
mainEntityOfPage: { "@type": "WebPage", "@id": url },
url,
keywords: post.tags?.join(", "),
};
return (
<article className="px-6 pb-24 pt-32 sm:pt-40 lg:px-10">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className="mx-auto max-w-3xl">
<Link
href="/devblog"
className="group mb-12 inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors hover:text-accent"
>
<span
aria-hidden
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
>
</span>
BACK TO DEVBLOG
</Link>
<header className="mb-16 border-b border-border pb-8">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 font-display text-xs tracking-[0.3em] text-accent">
<time dateTime={post.date}>
{formatDate(post.date).toUpperCase()}
</time>
<span aria-hidden className="text-border">
·
</span>
<span className="text-foreground-muted">{minutes} MIN READ</span>
</div>
<h1 className="mt-4 mb-6 font-display text-4xl leading-[0.95] tracking-wider sm:text-5xl lg:text-6xl">
{post.title.toUpperCase()}
</h1>
{post.excerpt && (
<p className="text-lg leading-relaxed text-foreground-muted">
{post.excerpt}
</p>
)}
<div className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-3">
{post.author && (
<p className="text-xs uppercase tracking-widest text-foreground-muted">
By {post.author}
</p>
)}
{/* The post declares a game in its frontmatter but the page never
showed it, so a reader had no way back to what it is about. */}
{relatedGame && (
<Link
href={`/games/${relatedGame.slug}`}
className="border border-accent/40 px-2 py-1 text-[0.65rem] uppercase tracking-widest text-accent transition duration-200 ease-out hover:-translate-y-0.5 hover:border-accent hover:bg-accent hover:text-background active:scale-95"
>
{relatedGame.title}
</Link>
)}
{post.tags && post.tags.length > 0 && (
<ul className="flex flex-wrap gap-2" aria-label="Tags">
{post.tags.map((tag) => (
<li
key={tag}
className="text-[0.65rem] uppercase tracking-widest text-foreground-muted"
>
#{tag}
</li>
))}
</ul>
)}
</div>
</header>
<div>
<MDXRemote
source={post.content}
components={mdxComponents}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
},
}}
/>
</div>
{(newer || older) && (
<nav
aria-label="Post navigation"
className="mt-20 pt-10 border-t border-border grid sm:grid-cols-2 gap-6"
>
{newer ? (
<Link
href={`/devblog/${newer.slug}`}
className="group block border border-border p-6 transition duration-300 ease-out hover:-translate-y-1 hover:border-accent"
>
<p className="mb-2 flex items-center gap-2 font-display text-xs tracking-[0.3em] text-foreground-muted transition-colors group-hover:text-accent">
<span
aria-hidden
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
>
</span>
NEWER
</p>
<p className="font-display text-lg tracking-wider group-hover:text-accent transition-colors">
{newer.title.toUpperCase()}
</p>
</Link>
) : (
<span aria-hidden />
)}
{older ? (
<Link
href={`/devblog/${older.slug}`}
className="group block border border-border p-6 transition duration-300 ease-out hover:-translate-y-1 hover:border-accent sm:text-right"
>
<p className="mb-2 flex items-center gap-2 font-display text-xs tracking-[0.3em] text-foreground-muted transition-colors group-hover:text-accent sm:justify-end">
OLDER
<span
aria-hidden
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
>
</span>
</p>
<p className="font-display text-lg tracking-wider group-hover:text-accent transition-colors">
{older.title.toUpperCase()}
</p>
</Link>
) : (
<span aria-hidden />
)}
</nav>
)}
{/* A post used to end on its own navigation. This is the moment a
reader is most likely to want the next one. */}
<aside className="mt-16 rounded-2xl border border-border bg-surface p-8 lg:p-10">
<h2 className="font-display text-2xl tracking-wider sm:text-3xl">
GET THE NEXT ONE BY EMAIL
</h2>
<p className="mt-4 leading-relaxed text-foreground-muted">
One mail per major update. No launch countdowns, no noise.
</p>
<div className="mt-8">
<Newsletter variant="compact" />
</div>
</aside>
</div>
</article>
);
}