feat: build game detail page with header, info panel, and screenshot gallery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
35
apps/web/src/app/[locale]/games/[slug]/page.tsx
Normal file
35
apps/web/src/app/[locale]/games/[slug]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getGameBySlug } from "@/lib/api";
|
||||
import { GameHeader } from "@/components/game/GameHeader";
|
||||
import { GameInfo } from "@/components/game/GameInfo";
|
||||
import { ScreenshotGallery } from "@/components/game/ScreenshotGallery";
|
||||
|
||||
export default async function GamePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
|
||||
let game;
|
||||
try {
|
||||
const res = await getGameBySlug(slug, locale);
|
||||
game = Array.isArray(res.data) ? res.data[0] : res.data;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!game) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameHeader game={game} />
|
||||
<div className="max-w-7xl mx-auto px-4 py-12">
|
||||
<GameInfo game={game} locale={locale} />
|
||||
{game.screenshots && (
|
||||
<ScreenshotGallery screenshots={game.screenshots} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
apps/web/src/components/game/GameHeader.tsx
Normal file
29
apps/web/src/components/game/GameHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Image from "next/image";
|
||||
import type { Game } from "@afterlife/shared";
|
||||
|
||||
interface GameHeaderProps {
|
||||
game: Game;
|
||||
}
|
||||
|
||||
export function GameHeader({ game }: GameHeaderProps) {
|
||||
return (
|
||||
<div className="relative h-[50vh] overflow-hidden">
|
||||
{game.coverImage && (
|
||||
<Image
|
||||
src={game.coverImage.url}
|
||||
alt={game.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<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">
|
||||
{game.developer} · {game.releaseYear}–{game.shutdownYear}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
apps/web/src/components/game/GameInfo.tsx
Normal file
79
apps/web/src/components/game/GameInfo.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import type { Game } from "@afterlife/shared";
|
||||
|
||||
interface GameInfoProps {
|
||||
game: Game;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function GameInfo({ game, locale }: GameInfoProps) {
|
||||
const t = useTranslations("game");
|
||||
|
||||
const statusColors = {
|
||||
online: "text-green-400",
|
||||
maintenance: "text-yellow-400",
|
||||
coming_soon: "text-blue-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="md:col-span-2">
|
||||
<div
|
||||
className="prose prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: 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>
|
||||
{game.publisher && (
|
||||
<div>
|
||||
<dt className="text-gray-500">{t("publisher")}</dt>
|
||||
<dd className="text-white 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>
|
||||
<div>
|
||||
<dt className="text-gray-500">{t("shutdown")}</dt>
|
||||
<dd className="text-white font-medium">{game.shutdownYear}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">{t("server_status")}</dt>
|
||||
<dd className={`font-medium ${statusColors[game.serverStatus]}`}>
|
||||
{t(`status_${game.serverStatus}`)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-6 space-y-3">
|
||||
{game.serverLink && game.serverStatus === "online" && (
|
||||
<a
|
||||
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"
|
||||
>
|
||||
{t("play_now")}
|
||||
</a>
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
{t("view_documentary")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/components/game/ScreenshotGallery.tsx
Normal file
48
apps/web/src/components/game/ScreenshotGallery.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import type { StrapiMedia } from "@afterlife/shared";
|
||||
|
||||
interface ScreenshotGalleryProps {
|
||||
screenshots: StrapiMedia[];
|
||||
}
|
||||
|
||||
export function ScreenshotGallery({ screenshots }: ScreenshotGalleryProps) {
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
if (screenshots.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden mb-4">
|
||||
<Image
|
||||
src={screenshots[selected].url}
|
||||
alt={screenshots[selected].alternativeText || "Screenshot"}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
{screenshots.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{screenshots.map((ss, i) => (
|
||||
<button
|
||||
key={ss.id}
|
||||
onClick={() => setSelected(i)}
|
||||
className={`relative w-24 h-16 rounded overflow-hidden flex-shrink-0 border-2 transition-colors ${
|
||||
i === selected ? "border-blue-500" : "border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={ss.url}
|
||||
alt={ss.alternativeText || `Screenshot ${i + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user