Files
highland_games_website/app/devblog/page.tsx
T
2026-06-22 23:03:02 +02:00

207 lines
7.4 KiB
TypeScript

import Link from "next/link";
import type { Metadata } from "next";
import { getAllPosts } from "@/lib/devblog";
import { games as gameRegistry } from "@/lib/games";
import { Newsletter } from "../components/Newsletter";
export const metadata: Metadata = {
title: "Devblog",
description:
"Behind-the-scenes updates, design decisions, and milestones from Highland Games Studio.",
};
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);
if (params.page && params.page > 1) search.set("page", String(params.page));
const qs = search.toString();
return qs ? `/devblog?${qs}` : "/devblog";
}
type Props = {
searchParams: Promise<{ game?: string; page?: string }>;
};
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
.map((slug) => ({
slug,
title: gameRegistry.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 currentPage = Math.max(1, parseInt(page ?? "1", 10) || 1);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
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>
{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">
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"
}`}
>
All
</Link>
{gameOptions.map((g) => (
<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"
}`}
>
{g.title}
</Link>
))}
</div>
)}
{visible.length === 0 ? (
<p className="text-foreground-muted">
{game
? "No posts match these filters 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>
);
})}
</div>
)}
{totalPages > 1 && (
<nav
aria-label="Pagination"
className="flex items-center justify-between mt-12 pt-8 border-t border-border"
>
{safePage > 1 ? (
<Link
href={buildHref({ game, page: safePage - 1 })}
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
>
NEWER
</Link>
) : (
<span aria-hidden />
)}
<span className="font-display text-xs tracking-widest text-foreground-muted">
PAGE {safePage} / {totalPages}
</span>
{safePage < totalPages ? (
<Link
href={buildHref({ game, page: safePage + 1 })}
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
>
OLDER
</Link>
) : (
<span aria-hidden />
)}
</nav>
)}
<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&apos;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>
<Newsletter variant="compact" />
</section>
</div>
</div>
);
}