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:
+159
-47
@@ -3,8 +3,11 @@ 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 { SITE_URL, LOGO_URL } from "@/lib/site";
|
||||
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 }> };
|
||||
|
||||
@@ -38,31 +41,25 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
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>) => (
|
||||
<h1
|
||||
<h2
|
||||
{...props}
|
||||
className="font-display text-4xl sm:text-5xl tracking-wider mt-12 mb-6 first:mt-0"
|
||||
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="font-display text-3xl tracking-wider mt-12 mb-4"
|
||||
/>
|
||||
<h2 {...props} className="mt-12 mb-4 font-display text-3xl tracking-wider" />
|
||||
),
|
||||
h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3
|
||||
<h3 {...props} className="mt-8 mb-3 font-display text-2xl tracking-wider" />
|
||||
),
|
||||
h4: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h4
|
||||
{...props}
|
||||
className="font-display text-2xl tracking-wider mt-8 mb-3"
|
||||
className="mt-8 mb-3 font-display text-xl tracking-wider text-foreground"
|
||||
/>
|
||||
),
|
||||
p: (props: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
@@ -95,10 +92,57 @@ const mdxComponents = {
|
||||
className="border-l-2 border-accent pl-6 my-6 italic text-foreground"
|
||||
/>
|
||||
),
|
||||
code: (props: React.HTMLAttributes<HTMLElement>) => (
|
||||
<code
|
||||
// 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="bg-surface-elevated border border-border px-1.5 py-0.5 rounded text-sm text-accent"
|
||||
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" />,
|
||||
@@ -110,6 +154,10 @@ export default async function DevblogPost({ params }: Props) {
|
||||
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",
|
||||
@@ -120,11 +168,11 @@ export default async function DevblogPost({ params }: Props) {
|
||||
dateModified: post.date,
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
name: post.author ?? "Highland Games Studio",
|
||||
name: post.author ?? STUDIO.name,
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Highland Games Studio",
|
||||
name: STUDIO.name,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: LOGO_URL,
|
||||
@@ -136,42 +184,80 @@ export default async function DevblogPost({ params }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="pt-32 pb-24 px-6 lg:px-10">
|
||||
<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="max-w-3xl mx-auto">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Link
|
||||
href="/devblog"
|
||||
className="inline-block font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors mb-12"
|
||||
className="group mb-12 inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors hover:text-accent"
|
||||
>
|
||||
← BACK TO DEVBLOG
|
||||
<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 pb-8 border-b border-border">
|
||||
<time
|
||||
dateTime={post.date}
|
||||
className="font-display text-xs tracking-[0.3em] text-accent"
|
||||
>
|
||||
{formatDate(post.date).toUpperCase()}
|
||||
</time>
|
||||
<h1 className="font-display text-4xl sm:text-5xl lg:text-6xl tracking-wider leading-[0.95] mt-4 mb-6">
|
||||
<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 text-foreground-muted leading-relaxed">
|
||||
<p className="text-lg leading-relaxed text-foreground-muted">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
{post.author && (
|
||||
<p className="text-xs tracking-widest text-foreground-muted mt-6 uppercase">
|
||||
By {post.author}
|
||||
</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 className="prose-custom">
|
||||
<div>
|
||||
<MDXRemote
|
||||
source={post.content}
|
||||
components={mdxComponents}
|
||||
@@ -191,10 +277,16 @@ export default async function DevblogPost({ params }: Props) {
|
||||
{newer ? (
|
||||
<Link
|
||||
href={`/devblog/${newer.slug}`}
|
||||
className="group block border border-border p-6 hover:border-accent transition-colors"
|
||||
className="group block border border-border p-6 transition duration-300 ease-out hover:-translate-y-1 hover:border-accent"
|
||||
>
|
||||
<p className="font-display text-xs tracking-[0.3em] text-foreground-muted group-hover:text-accent transition-colors mb-2">
|
||||
← NEWER
|
||||
<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()}
|
||||
@@ -206,10 +298,16 @@ export default async function DevblogPost({ params }: Props) {
|
||||
{older ? (
|
||||
<Link
|
||||
href={`/devblog/${older.slug}`}
|
||||
className="group block border border-border p-6 hover:border-accent transition-colors sm:text-right"
|
||||
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="font-display text-xs tracking-[0.3em] text-foreground-muted group-hover:text-accent transition-colors mb-2">
|
||||
OLDER →
|
||||
<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()}
|
||||
@@ -220,6 +318,20 @@ export default async function DevblogPost({ params }: Props) {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
+90
-117
@@ -1,8 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "../components/ui/PageHeader";
|
||||
import { Section } from "../components/ui/Section";
|
||||
import { SectionHeading } from "../components/ui/SectionHeading";
|
||||
import { PostList } from "../components/devblog/PostList";
|
||||
import { PostFeature } from "../components/devblog/PostFeature";
|
||||
import { Newsletter } from "../components/forms/Newsletter";
|
||||
import { getAllPosts } from "@/lib/devblog";
|
||||
import { games as gameRegistry } from "@/lib/games";
|
||||
import { Newsletter } from "../components/Newsletter";
|
||||
import { games } from "@/lib/games";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Devblog",
|
||||
@@ -12,14 +17,6 @@ export const metadata: Metadata = {
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function buildHref(params: { game?: string; page?: number }) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.game) search.set("game", params.game);
|
||||
@@ -28,6 +25,13 @@ function buildHref(params: { game?: string; page?: number }) {
|
||||
return qs ? `/devblog?${qs}` : "/devblog";
|
||||
}
|
||||
|
||||
const filterClass = (active: boolean) =>
|
||||
`border px-3 py-1.5 text-xs uppercase tracking-widest transition duration-200 ease-out active:scale-95 ${
|
||||
active
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-foreground-muted hover:-translate-y-0.5 hover:border-accent hover:text-accent"
|
||||
}`;
|
||||
|
||||
type Props = {
|
||||
searchParams: Promise<{ game?: string; page?: string }>;
|
||||
};
|
||||
@@ -36,138 +40,103 @@ export default async function DevblogPage({ searchParams }: Props) {
|
||||
const { game, page } = await searchParams;
|
||||
const allPosts = getAllPosts();
|
||||
|
||||
const usedGameSlugs = Array.from(
|
||||
new Set(allPosts.map((p) => p.game).filter((g): g is string => Boolean(g))),
|
||||
);
|
||||
const gameOptions = usedGameSlugs
|
||||
// Only offer a filter for games that actually have posts.
|
||||
const gameOptions = Array.from(
|
||||
new Set(allPosts.map((post) => post.game).filter(Boolean) as string[]),
|
||||
)
|
||||
.map((slug) => ({
|
||||
slug,
|
||||
title: gameRegistry.find((g) => g.slug === slug)?.title ?? slug,
|
||||
title: games.find((g) => g.slug === slug)?.title ?? slug,
|
||||
}))
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
const filtered = allPosts.filter((p) => {
|
||||
if (game && p.game !== game) return false;
|
||||
return true;
|
||||
});
|
||||
const filtered = game
|
||||
? allPosts.filter((post) => post.game === game)
|
||||
: allPosts;
|
||||
|
||||
const currentPage = Math.max(1, parseInt(page ?? "1", 10) || 1);
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const currentPage = Math.max(1, Number.parseInt(page ?? "1", 10) || 1);
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
const visible = filtered.slice(
|
||||
(safePage - 1) * PAGE_SIZE,
|
||||
safePage * PAGE_SIZE,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pt-32 pb-24 px-6 lg:px-10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
||||
— DEVBLOG
|
||||
</p>
|
||||
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
|
||||
THE JOURNEY,
|
||||
<br />
|
||||
ONE STEP AT A TIME.
|
||||
</h1>
|
||||
<p className="text-lg text-foreground-muted leading-relaxed max-w-2xl mb-12">
|
||||
Honest updates from the studio. Design decisions, prototypes, art
|
||||
milestones everything that goes into building our worlds.
|
||||
</p>
|
||||
// The newest post leads the page, but only where "newest" means anything:
|
||||
// not on page two, and not inside a filtered view.
|
||||
const featured = safePage === 1 && !game ? visible[0] : undefined;
|
||||
const listed = featured ? visible.slice(1) : visible;
|
||||
|
||||
{gameOptions.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-12">
|
||||
<span className="font-display text-xs tracking-[0.3em] text-foreground-muted mr-2">
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="DEVBLOG"
|
||||
width="narrow"
|
||||
title={
|
||||
<>
|
||||
THE JOURNEY,
|
||||
<br />
|
||||
ONE STEP AT A TIME.
|
||||
</>
|
||||
}
|
||||
lead="Honest updates from the studio: design decisions, prototypes, art milestones, and everything else that goes into building our worlds."
|
||||
/>
|
||||
|
||||
<Section top="none" width="narrow">
|
||||
{/* One game means the filter offers "All" and "All, again". */}
|
||||
{gameOptions.length > 1 && (
|
||||
<div className="mb-10 flex flex-wrap items-center gap-2">
|
||||
<span className="mr-2 font-display text-xs tracking-[0.3em] text-foreground-muted">
|
||||
GAME
|
||||
</span>
|
||||
<Link
|
||||
href={buildHref({})}
|
||||
className={`text-xs tracking-widest uppercase border px-3 py-1.5 transition-colors ${
|
||||
!game
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-foreground-muted hover:border-accent hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
<Link href={buildHref({})} className={filterClass(!game)}>
|
||||
All
|
||||
</Link>
|
||||
{gameOptions.map((g) => (
|
||||
{gameOptions.map((option) => (
|
||||
<Link
|
||||
key={g.slug}
|
||||
href={buildHref({ game: g.slug })}
|
||||
className={`text-xs tracking-widest uppercase border px-3 py-1.5 transition-colors ${
|
||||
game === g.slug
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-foreground-muted hover:border-accent hover:text-accent"
|
||||
}`}
|
||||
key={option.slug}
|
||||
href={buildHref({ game: option.slug })}
|
||||
className={filterClass(game === option.slug)}
|
||||
>
|
||||
{g.title}
|
||||
{option.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
{visible.length === 0 && (
|
||||
<p className="text-foreground-muted">
|
||||
{game
|
||||
? "No posts match these filters yet."
|
||||
? "No posts for this game yet."
|
||||
: "No posts yet. Check back soon."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{visible.map((post) => {
|
||||
const postGame = post.game
|
||||
? gameRegistry.find((g) => g.slug === post.game)?.title ??
|
||||
post.game
|
||||
: null;
|
||||
return (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/devblog/${post.slug}`}
|
||||
className="group block py-8 border-t border-border hover:border-accent transition-colors"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-2 mb-3">
|
||||
<h2 className="font-display text-2xl sm:text-3xl tracking-wider group-hover:text-accent transition-colors">
|
||||
{post.title.toUpperCase()}
|
||||
</h2>
|
||||
<time
|
||||
dateTime={post.date}
|
||||
className="text-xs tracking-widest text-foreground-muted shrink-0"
|
||||
>
|
||||
{formatDate(post.date)}
|
||||
</time>
|
||||
</div>
|
||||
{post.excerpt && (
|
||||
<p className="text-foreground-muted leading-relaxed max-w-2xl">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
{postGame && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
<span className="text-[0.65rem] tracking-widest uppercase border border-accent/40 text-accent px-2 py-1">
|
||||
{postGame}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="inline-block mt-4 font-display text-xs tracking-widest text-foreground-muted group-hover:text-accent transition-colors">
|
||||
READ →
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
|
||||
{featured && <PostFeature post={featured} eyebrow="Latest" as="h2" />}
|
||||
|
||||
{listed.length > 0 && (
|
||||
<div className={featured ? "mt-12" : undefined}>
|
||||
<PostList posts={listed} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
className="flex items-center justify-between mt-12 pt-8 border-t border-border"
|
||||
className="mt-12 flex items-center justify-between"
|
||||
>
|
||||
{safePage > 1 ? (
|
||||
<Link
|
||||
href={buildHref({ game, page: safePage - 1 })}
|
||||
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
|
||||
className="group inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors hover:text-accent"
|
||||
>
|
||||
← NEWER
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
|
||||
>
|
||||
←
|
||||
</span>
|
||||
NEWER
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
@@ -178,29 +147,33 @@ export default async function DevblogPage({ searchParams }: Props) {
|
||||
{safePage < totalPages ? (
|
||||
<Link
|
||||
href={buildHref({ game, page: safePage + 1 })}
|
||||
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
|
||||
className="group inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors hover:text-accent"
|
||||
>
|
||||
OLDER →
|
||||
OLDER
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<section className="mt-24 border-t border-border pt-16">
|
||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-3">
|
||||
— NEWSLETTER
|
||||
</p>
|
||||
<h2 className="font-display text-3xl sm:text-4xl tracking-wider mb-4">
|
||||
DON'T MISS A SUMMIT
|
||||
</h2>
|
||||
<p className="text-foreground-muted leading-relaxed max-w-2xl mb-6">
|
||||
Subscribe to get devblog highlights and release news in your inbox.
|
||||
</p>
|
||||
<Section divider width="narrow">
|
||||
<SectionHeading
|
||||
eyebrow="NEWSLETTER"
|
||||
title="DON'T MISS A SUMMIT"
|
||||
lead="Subscribe to get devblog highlights and release news in your inbox."
|
||||
/>
|
||||
<div className="mt-10">
|
||||
<Newsletter variant="compact" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user