(Feat) Init WebSite
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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 { getAdjacentPosts, getAllPosts, getPost } from "@/lib/devblog";
|
||||
|
||||
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 = `https://highlandgamesstudio.com/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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
const mdxComponents = {
|
||||
h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1
|
||||
{...props}
|
||||
className="font-display text-4xl sm:text-5xl tracking-wider mt-12 mb-6 first:mt-0"
|
||||
/>
|
||||
),
|
||||
h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h2
|
||||
{...props}
|
||||
className="font-display text-3xl tracking-wider mt-12 mb-4"
|
||||
/>
|
||||
),
|
||||
h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3
|
||||
{...props}
|
||||
className="font-display text-2xl tracking-wider mt-8 mb-3"
|
||||
/>
|
||||
),
|
||||
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"
|
||||
/>
|
||||
),
|
||||
code: (props: React.HTMLAttributes<HTMLElement>) => (
|
||||
<code
|
||||
{...props}
|
||||
className="bg-surface-elevated border border-border px-1.5 py-0.5 rounded text-sm text-accent"
|
||||
/>
|
||||
),
|
||||
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 url = `https://highlandgamesstudio.com/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 ?? "Highland Games Studio",
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Highland Games Studio",
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: "https://highlandgamesstudio.com/image/Studio/Logo Flat.png",
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: { "@type": "WebPage", "@id": url },
|
||||
url,
|
||||
keywords: post.tags?.join(", "),
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="pt-32 pb-24 px-6 lg:px-10">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Link
|
||||
href="/devblog"
|
||||
className="inline-block font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors mb-12"
|
||||
>
|
||||
← 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">
|
||||
{post.title.toUpperCase()}
|
||||
</h1>
|
||||
{post.excerpt && (
|
||||
<p className="text-lg text-foreground-muted leading-relaxed">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
{post.author && (
|
||||
<p className="text-xs tracking-widest text-foreground-muted mt-6 uppercase">
|
||||
By {post.author}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="prose-custom">
|
||||
<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 hover:border-accent transition-colors"
|
||||
>
|
||||
<p className="font-display text-xs tracking-[0.3em] text-foreground-muted group-hover:text-accent transition-colors mb-2">
|
||||
← 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 hover:border-accent transition-colors 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>
|
||||
<p className="font-display text-lg tracking-wider group-hover:text-accent transition-colors">
|
||||
{older.title.toUpperCase()}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user