feat: add game server infrastructure and documentary improvements
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
- Add Docker Compose for OpenFusion (FusionFall), MapleStory 2, and Minecraft FTB Infinity Evolved game servers - Add MapleStory 2 multi-service compose (MySQL, World, Login, Web, Game) - Add OpenFusion Dockerfile and configuration files - Fix CMS Dockerfile, web Dockerfile, and documentary components - Add root layout, globals.css, not-found page, and text formatting utils - Update .gitignore to exclude large game server repos and data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ export default async function GamePage({
|
||||
return (
|
||||
<>
|
||||
<GameHeader game={game} />
|
||||
<div className="max-w-7xl mx-auto px-4 py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<GameInfo game={game} locale={locale} />
|
||||
{game.screenshots && (
|
||||
<ScreenshotGallery screenshots={game.screenshots} />
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Playfair_Display, Source_Serif_4, DM_Sans } from "next/font/google";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import "./globals.css";
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-playfair",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin", "latin-ext"],
|
||||
variable: "--font-source-serif",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-dm-sans",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -30,8 +48,11 @@ export default async function LocaleLayout({
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body className="bg-gray-950 text-white min-h-screen flex flex-col">
|
||||
<html
|
||||
lang={locale}
|
||||
className={`${playfair.variable} ${sourceSerif.variable} ${dmSans.variable}`}
|
||||
>
|
||||
<body className="bg-gray-950 text-white antialiased min-h-screen flex flex-col font-sans">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Navbar />
|
||||
<main className="flex-1 pt-16">{children}</main>
|
||||
|
||||
54
apps/web/src/app/globals.css
Normal file
54
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,54 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-dm-sans), system-ui, sans-serif;
|
||||
--font-display: var(--font-playfair), Georgia, serif;
|
||||
--font-body: var(--font-source-serif), Georgia, serif;
|
||||
}
|
||||
|
||||
/* ── Editorial prose — game descriptions ────────────────── */
|
||||
|
||||
.prose-editorial p {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.85;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.prose-editorial p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Chapter reading experience ─────────────────────────── */
|
||||
|
||||
.chapter-prose p {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.1875rem;
|
||||
line-height: 1.9;
|
||||
color: #e5e7eb;
|
||||
margin-bottom: 1.75em;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
|
||||
.chapter-prose > p:first-of-type::first-letter {
|
||||
float: left;
|
||||
font-family: var(--font-display);
|
||||
font-size: 3.5rem;
|
||||
line-height: 1;
|
||||
padding-right: 0.5rem;
|
||||
margin-top: 0.1rem;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.chapter-prose p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Em-dash and quotation styling inside prose ─────────── */
|
||||
|
||||
.chapter-prose p em {
|
||||
font-style: italic;
|
||||
color: #fbbf24;
|
||||
}
|
||||
9
apps/web/src/app/layout.tsx
Normal file
9
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import "./globals.css";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return children;
|
||||
}
|
||||
13
apps/web/src/app/not-found.tsx
Normal file
13
apps/web/src/app/not-found.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function RootNotFound() {
|
||||
return (
|
||||
<html lang="es">
|
||||
<body style={{ backgroundColor: "#030712", color: "#fff", fontFamily: "system-ui", display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", margin: 0 }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "3rem", marginBottom: "1rem" }}>404</h1>
|
||||
<p style={{ color: "#9ca3af" }}>Page not found</p>
|
||||
<a href="/es" style={{ color: "#60a5fa", marginTop: "1rem", display: "inline-block" }}>Go home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import type { Chapter } from "@afterlife/shared";
|
||||
import { formatTextToHtml } from "@/lib/format";
|
||||
|
||||
interface ChapterContentProps {
|
||||
chapter: Chapter;
|
||||
@@ -7,9 +8,22 @@ interface ChapterContentProps {
|
||||
|
||||
export function ChapterContent({ chapter }: ChapterContentProps) {
|
||||
return (
|
||||
<article className="max-w-3xl">
|
||||
<article className="max-w-2xl mx-auto">
|
||||
{/* Chapter indicator */}
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<span className="text-amber-500/80 font-display text-sm tracking-[0.2em]">
|
||||
{String(chapter.order).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-white/10" />
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-4xl font-display font-bold leading-tight tracking-tight">
|
||||
{chapter.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{chapter.coverImage && (
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden mb-8">
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden mb-10">
|
||||
<Image
|
||||
src={chapter.coverImage.url}
|
||||
alt={chapter.coverImage.alternativeText || chapter.title}
|
||||
@@ -18,10 +32,10 @@ export function ChapterContent({ chapter }: ChapterContentProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-3xl font-bold mb-6">{chapter.title}</h2>
|
||||
|
||||
<div
|
||||
className="prose prose-invert prose-lg max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: chapter.content }}
|
||||
className="chapter-prose"
|
||||
dangerouslySetInnerHTML={{ __html: formatTextToHtml(chapter.content) }}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -9,27 +9,39 @@ interface ChapterNavProps {
|
||||
onSelectChapter: (id: number, index: number) => void;
|
||||
}
|
||||
|
||||
export function ChapterNav({ chapters, activeChapterId, onSelectChapter }: ChapterNavProps) {
|
||||
export function ChapterNav({
|
||||
chapters,
|
||||
activeChapterId,
|
||||
onSelectChapter,
|
||||
}: ChapterNavProps) {
|
||||
const t = useTranslations("documentary");
|
||||
|
||||
return (
|
||||
<nav className="w-64 flex-shrink-0 hidden lg:block">
|
||||
<div className="sticky top-20">
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
|
||||
<nav className="w-72 flex-shrink-0 hidden lg:block">
|
||||
<div className="sticky top-24">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-[0.15em] mb-5">
|
||||
{t("chapters")}
|
||||
</h3>
|
||||
<ol className="space-y-1">
|
||||
<ol className="space-y-0.5">
|
||||
{chapters.map((chapter, index) => (
|
||||
<li key={chapter.id}>
|
||||
<button
|
||||
onClick={() => onSelectChapter(chapter.id, index)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 ${
|
||||
chapter.id === activeChapterId
|
||||
? "bg-blue-600/20 text-blue-400 font-medium"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
? "bg-amber-500/10 text-amber-400 font-medium border-l-2 border-amber-500 rounded-l-none"
|
||||
: "text-gray-400 hover:text-gray-200 hover:bg-white/[0.03]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-gray-600 mr-2">{index + 1}.</span>
|
||||
<span
|
||||
className={`text-xs mr-2 tabular-nums ${
|
||||
chapter.id === activeChapterId
|
||||
? "text-amber-500/70"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{chapter.title}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -37,6 +37,7 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||
const chapter = chapters.find((c) => c.id === chapterId);
|
||||
if (chapter) {
|
||||
setActiveChapter(chapter);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
const trackIndex = audio.tracks.findIndex((t) => t.id === chapterId);
|
||||
if (trackIndex !== -1) {
|
||||
audio.goToTrack(trackIndex);
|
||||
@@ -47,13 +48,28 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<ReadingProgress />
|
||||
<div className="max-w-7xl mx-auto px-4 py-12 flex gap-8">
|
||||
|
||||
{/* Documentary header */}
|
||||
<header className="border-b border-white/[0.06]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-10 pb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-display font-bold tracking-tight">
|
||||
{documentary.title}
|
||||
</h1>
|
||||
{documentary.description && (
|
||||
<p className="mt-3 text-gray-400 font-body text-lg max-w-3xl leading-relaxed">
|
||||
{documentary.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex gap-12">
|
||||
<ChapterNav
|
||||
chapters={chapters}
|
||||
activeChapterId={activeChapter.id}
|
||||
onSelectChapter={handleSelectChapter}
|
||||
/>
|
||||
<div className="flex-1 pb-24">
|
||||
<div className="flex-1 min-w-0 pb-24">
|
||||
<ChapterContent chapter={activeChapter} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,7 +83,9 @@ export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
|
||||
onToggle={audio.toggle}
|
||||
onSeek={audio.seek}
|
||||
onChangeRate={audio.changeRate}
|
||||
onToggleContinuous={() => audio.setContinuousMode(!audio.continuousMode)}
|
||||
onToggleContinuous={() =>
|
||||
audio.setContinuousMode(!audio.continuousMode)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,8 +19,10 @@ export function GameHeader({ game }: GameHeaderProps) {
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-950 via-gray-950/60 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8 max-w-7xl mx-auto">
|
||||
<h1 className="text-5xl font-bold mb-2">{game.title}</h1>
|
||||
<p className="text-gray-400 text-lg">
|
||||
<h1 className="text-5xl font-bold mb-3 font-display tracking-tight">
|
||||
{game.title}
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg font-body">
|
||||
{game.developer} · {game.releaseYear}–{game.shutdownYear}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import type { Game } from "@afterlife/shared";
|
||||
import { formatTextToHtml } from "@/lib/format";
|
||||
|
||||
interface GameInfoProps {
|
||||
game: Game;
|
||||
@@ -17,36 +18,46 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div className="md:col-span-2">
|
||||
<div
|
||||
className="prose prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: game.description }}
|
||||
className="prose-editorial"
|
||||
dangerouslySetInnerHTML={{ __html: formatTextToHtml(game.description) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-900 rounded-lg p-6 border border-white/5">
|
||||
<dl className="space-y-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500">{t("developer")}</dt>
|
||||
<dd className="text-white font-medium">{game.developer}</dd>
|
||||
<div className="bg-gradient-to-b from-gray-900 to-gray-900/50 rounded-xl p-6 border border-white/[0.07]">
|
||||
<dl className="divide-y divide-white/5 text-sm">
|
||||
<div className="pb-3">
|
||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
||||
{t("developer")}
|
||||
</dt>
|
||||
<dd className="text-gray-100 font-medium">{game.developer}</dd>
|
||||
</div>
|
||||
{game.publisher && (
|
||||
<div>
|
||||
<dt className="text-gray-500">{t("publisher")}</dt>
|
||||
<dd className="text-white font-medium">{game.publisher}</dd>
|
||||
<div className="py-3">
|
||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
||||
{t("publisher")}
|
||||
</dt>
|
||||
<dd className="text-gray-100 font-medium">{game.publisher}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-gray-500">{t("released")}</dt>
|
||||
<dd className="text-white font-medium">{game.releaseYear}</dd>
|
||||
<div className="py-3">
|
||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
||||
{t("released")}
|
||||
</dt>
|
||||
<dd className="text-gray-100 font-medium">{game.releaseYear}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">{t("shutdown")}</dt>
|
||||
<dd className="text-white font-medium">{game.shutdownYear}</dd>
|
||||
<div className="py-3">
|
||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
||||
{t("shutdown")}
|
||||
</dt>
|
||||
<dd className="text-gray-100 font-medium">{game.shutdownYear}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">{t("server_status")}</dt>
|
||||
<div className="pt-3">
|
||||
<dt className="text-gray-500 text-xs uppercase tracking-wider mb-1">
|
||||
{t("server_status")}
|
||||
</dt>
|
||||
<dd className={`font-medium ${statusColors[game.serverStatus]}`}>
|
||||
{t(`status_${game.serverStatus}`)}
|
||||
</dd>
|
||||
@@ -58,7 +69,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
href={game.serverLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full text-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
|
||||
className="block w-full text-center px-4 py-2.5 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-colors font-medium text-sm"
|
||||
>
|
||||
{t("play_now")}
|
||||
</a>
|
||||
@@ -66,7 +77,7 @@ export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
{game.documentary && (
|
||||
<Link
|
||||
href={`/${locale}/games/${game.slug}/documentary`}
|
||||
className="block w-full text-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
|
||||
className="block w-full text-center px-4 py-2.5 bg-amber-600 hover:bg-amber-500 text-white rounded-lg transition-colors font-medium text-sm"
|
||||
>
|
||||
{t("view_documentary")}
|
||||
</Link>
|
||||
|
||||
@@ -12,8 +12,9 @@ export async function getGames(locale: string): Promise<StrapiListResponse<Game>
|
||||
path: "/games",
|
||||
locale,
|
||||
params: {
|
||||
"populate[coverImage]": "*",
|
||||
"populate[documentary]": "*",
|
||||
"populate[coverImage][fields][0]": "url",
|
||||
"populate[coverImage][fields][1]": "alternativeText",
|
||||
"populate[documentary][fields][0]": "title",
|
||||
"sort": "createdAt:desc",
|
||||
},
|
||||
});
|
||||
@@ -25,9 +26,19 @@ export async function getGameBySlug(slug: string, locale: string): Promise<Strap
|
||||
locale,
|
||||
params: {
|
||||
"filters[slug][$eq]": slug,
|
||||
"populate[coverImage]": "*",
|
||||
"populate[screenshots]": "*",
|
||||
"populate[documentary][populate][chapters][populate]": "*",
|
||||
"populate[coverImage][fields][0]": "url",
|
||||
"populate[coverImage][fields][1]": "alternativeText",
|
||||
"populate[coverImage][fields][2]": "width",
|
||||
"populate[coverImage][fields][3]": "height",
|
||||
"populate[screenshots][fields][0]": "url",
|
||||
"populate[screenshots][fields][1]": "alternativeText",
|
||||
"populate[documentary][populate][chapters][fields][0]": "title",
|
||||
"populate[documentary][populate][chapters][fields][1]": "content",
|
||||
"populate[documentary][populate][chapters][fields][2]": "order",
|
||||
"populate[documentary][populate][chapters][fields][3]": "audioDuration",
|
||||
"populate[documentary][populate][chapters][populate][audioFile][fields][0]": "url",
|
||||
"populate[documentary][populate][chapters][populate][coverImage][fields][0]": "url",
|
||||
"populate[documentary][populate][chapters][populate][coverImage][fields][1]": "alternativeText",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -49,8 +60,13 @@ export async function getChapter(
|
||||
path: `/chapters/${chapterId}`,
|
||||
locale,
|
||||
params: {
|
||||
"populate[audioFile]": "*",
|
||||
"populate[coverImage]": "*",
|
||||
"populate[audioFile][fields][0]": "url",
|
||||
"populate[audioFile][fields][1]": "name",
|
||||
"populate[audioFile][fields][2]": "mime",
|
||||
"populate[coverImage][fields][0]": "url",
|
||||
"populate[coverImage][fields][1]": "alternativeText",
|
||||
"populate[coverImage][fields][2]": "width",
|
||||
"populate[coverImage][fields][3]": "height",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
18
apps/web/src/lib/format.ts
Normal file
18
apps/web/src/lib/format.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Converts plain text with newline separators into HTML paragraphs.
|
||||
* If the text already contains HTML block elements, returns as-is.
|
||||
*/
|
||||
export function formatTextToHtml(text: string): string {
|
||||
if (!text) return "";
|
||||
|
||||
// If already contains HTML block elements, return as-is
|
||||
if (/<(?:p|div|h[1-6]|ul|ol|blockquote)\b/i.test(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return text
|
||||
.split(/\n\n+/)
|
||||
.filter((p) => p.trim())
|
||||
.map((p) => `<p>${p.trim().replace(/\n/g, "<br>")}</p>`)
|
||||
.join("");
|
||||
}
|
||||
Reference in New Issue
Block a user