Files
project-afterlife/docs/plans/2026-02-21-project-afterlife-implementation.md
consultoria-as 6671359367 Add Project Afterlife implementation plan
17 tasks across 6 phases: monorepo scaffold, Strapi CMS, Next.js frontend,
all pages including interactive documentary, Docker/Nginx, and CI/CD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 02:17:43 +00:00

2845 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Project Afterlife — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build the Project Afterlife web platform — a multilingual site for game preservation with interactive documentaries, powered by Next.js + Strapi CMS, fully self-hosted.
**Architecture:** Monorepo with Turborepo containing two apps (Next.js frontend + Strapi 5 CMS) and a shared types package. All services orchestrated via Docker Compose (Nginx, Node apps, PostgreSQL, MinIO). Content managed through Strapi's i18n plugin, consumed by Next.js via REST API.
**Tech Stack:** Next.js 15 (App Router), TypeScript, Tailwind CSS, Framer Motion, next-intl, Howler.js, Strapi 5, PostgreSQL, MinIO, Docker, Nginx
**Design doc:** `docs/plans/2026-02-21-project-afterlife-design.md`
---
## Phase 1: Monorepo Scaffold
### Task 1: Initialize monorepo root
**Files:**
- Create: `package.json`
- Create: `turbo.json`
- Create: `.gitignore`
- Create: `.nvmrc`
**Step 1: Create root package.json**
```json
{
"name": "project-afterlife",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"clean": "turbo clean"
},
"devDependencies": {
"turbo": "^2"
},
"packageManager": "npm@10.8.0"
}
```
**Step 2: Create turbo.json**
```json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"clean": {
"cache": false
}
}
}
```
**Step 3: Create .gitignore**
```
node_modules/
.next/
dist/
.env
.env.local
.env.*.local
*.log
.turbo/
.DS_Store
.tmp/
build/
```
**Step 4: Create .nvmrc**
```
20
```
**Step 5: Install turbo**
Run: `npm install`
Expected: turbo installed, node_modules created, package-lock.json generated
**Step 6: Commit**
```bash
git add package.json turbo.json .gitignore .nvmrc package-lock.json
git commit -m "feat: initialize monorepo with Turborepo"
```
---
### Task 2: Create shared types package
**Files:**
- Create: `packages/shared/package.json`
- Create: `packages/shared/tsconfig.json`
- Create: `packages/shared/src/types/index.ts`
- Create: `packages/shared/src/types/game.ts`
- Create: `packages/shared/src/types/documentary.ts`
- Create: `packages/shared/src/types/chapter.ts`
- Create: `packages/shared/src/types/api.ts`
- Create: `packages/shared/src/index.ts`
**Step 1: Create packages/shared/package.json**
```json
{
"name": "@afterlife/shared",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5"
}
}
```
**Step 2: Create packages/shared/tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
```
**Step 3: Create type files**
`packages/shared/src/types/game.ts`:
```typescript
export type Genre = "MMORPG" | "FPS" | "Casual" | "Strategy" | "Sports" | "Other";
export type ServerStatus = "online" | "maintenance" | "coming_soon";
export interface Game {
id: number;
title: string;
slug: string;
description: string;
genre: Genre;
releaseYear: number;
shutdownYear: number;
developer: string;
publisher: string;
screenshots: StrapiMedia[];
coverImage: StrapiMedia;
serverStatus: ServerStatus;
serverLink: string | null;
documentary: Documentary | null;
createdAt: string;
updatedAt: string;
locale: string;
}
// Forward reference — defined in documentary.ts
import type { Documentary } from "./documentary";
import type { StrapiMedia } from "./api";
```
`packages/shared/src/types/documentary.ts`:
```typescript
import type { Chapter } from "./chapter";
export interface Documentary {
id: number;
title: string;
description: string;
chapters: Chapter[];
publishedAt: string | null;
createdAt: string;
updatedAt: string;
locale: string;
}
```
`packages/shared/src/types/chapter.ts`:
```typescript
import type { StrapiMedia } from "./api";
export interface Chapter {
id: number;
title: string;
content: string; // Rich text (HTML/Markdown from Strapi)
audioFile: StrapiMedia | null;
audioDuration: number | null; // seconds
order: number;
coverImage: StrapiMedia | null;
locale: string;
}
```
`packages/shared/src/types/api.ts`:
```typescript
export interface StrapiMedia {
id: number;
url: string;
alternativeText: string | null;
width: number | null;
height: number | null;
mime: string;
name: string;
}
export interface StrapiResponse<T> {
data: T;
meta: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface StrapiListResponse<T> {
data: T[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
```
`packages/shared/src/types/index.ts`:
```typescript
export * from "./game";
export * from "./documentary";
export * from "./chapter";
export * from "./api";
```
`packages/shared/src/index.ts`:
```typescript
export * from "./types";
```
**Step 4: Install shared package dependencies**
Run: `cd packages/shared && npm install`
**Step 5: Verify types compile**
Run: `cd packages/shared && npx tsc --noEmit`
Expected: No errors
**Step 6: Commit**
```bash
git add packages/shared/
git commit -m "feat: add shared TypeScript types for Game, Documentary, Chapter"
```
---
## Phase 2: Strapi CMS Setup
### Task 3: Scaffold Strapi 5 app
**Files:**
- Create: `apps/cms/` (Strapi scaffold)
**Step 1: Create Strapi project**
Run: `npx create-strapi@latest apps/cms --quickstart --no-run --typescript`
> Note: After scaffold, remove the default SQLite config and configure PostgreSQL.
**Step 2: Update apps/cms/package.json** — set name to `@afterlife/cms`
Change name field to `"@afterlife/cms"`.
**Step 3: Configure PostgreSQL database**
Modify `apps/cms/config/database.ts`:
```typescript
export default ({ env }) => ({
connection: {
client: "postgres",
connection: {
host: env("DATABASE_HOST", "127.0.0.1"),
port: env.int("DATABASE_PORT", 5432),
database: env("DATABASE_NAME", "afterlife"),
user: env("DATABASE_USERNAME", "afterlife"),
password: env("DATABASE_PASSWORD", "afterlife"),
ssl: env.bool("DATABASE_SSL", false),
},
},
});
```
**Step 4: Create apps/cms/.env**
```
HOST=0.0.0.0
PORT=1337
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=your-api-token-salt
ADMIN_JWT_SECRET=your-admin-jwt-secret
TRANSFER_TOKEN_SALT=your-transfer-token-salt
JWT_SECRET=your-jwt-secret
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
DATABASE_NAME=afterlife
DATABASE_USERNAME=afterlife
DATABASE_PASSWORD=afterlife
```
**Step 5: Enable i18n plugin**
Modify `apps/cms/config/plugins.ts`:
```typescript
export default () => ({
i18n: {
enabled: true,
config: {
defaultLocale: "es",
locales: ["es", "en"],
},
},
});
```
**Step 6: Add apps/cms/.env to .gitignore** (already covered by root .gitignore `*.env*`)
**Step 7: Commit**
```bash
git add apps/cms/
git commit -m "feat: scaffold Strapi 5 CMS with PostgreSQL and i18n config"
```
---
### Task 4: Define Strapi content types
**Files:**
- Create: `apps/cms/src/api/game/content-types/game/schema.json`
- Create: `apps/cms/src/api/documentary/content-types/documentary/schema.json`
- Create: `apps/cms/src/api/chapter/content-types/chapter/schema.json`
> Note: Strapi 5 auto-generates routes, controllers, and services from schema.json files.
**Step 1: Create Game content type**
Create directory structure: `apps/cms/src/api/game/content-types/game/`
`apps/cms/src/api/game/content-types/game/schema.json`:
```json
{
"kind": "collectionType",
"collectionName": "games",
"info": {
"singularName": "game",
"pluralName": "games",
"displayName": "Game",
"description": "A preserved online game"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"pluginOptions": {
"i18n": { "localized": true }
}
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true
},
"description": {
"type": "richtext",
"pluginOptions": {
"i18n": { "localized": true }
}
},
"genre": {
"type": "enumeration",
"enum": ["MMORPG", "FPS", "Casual", "Strategy", "Sports", "Other"],
"required": true
},
"releaseYear": {
"type": "integer",
"required": true
},
"shutdownYear": {
"type": "integer",
"required": true
},
"developer": {
"type": "string",
"required": true
},
"publisher": {
"type": "string"
},
"screenshots": {
"type": "media",
"multiple": true,
"allowedTypes": ["images"]
},
"coverImage": {
"type": "media",
"multiple": false,
"required": true,
"allowedTypes": ["images"]
},
"serverStatus": {
"type": "enumeration",
"enum": ["online", "maintenance", "coming_soon"],
"default": "coming_soon",
"required": true
},
"serverLink": {
"type": "string"
},
"documentary": {
"type": "relation",
"relation": "oneToOne",
"target": "api::documentary.documentary",
"inversedBy": "game"
}
}
}
```
**Step 2: Create Documentary content type**
Create directory structure: `apps/cms/src/api/documentary/content-types/documentary/`
`apps/cms/src/api/documentary/content-types/documentary/schema.json`:
```json
{
"kind": "collectionType",
"collectionName": "documentaries",
"info": {
"singularName": "documentary",
"pluralName": "documentaries",
"displayName": "Documentary",
"description": "Interactive documentary for a game"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"pluginOptions": {
"i18n": { "localized": true }
}
},
"description": {
"type": "text",
"pluginOptions": {
"i18n": { "localized": true }
}
},
"game": {
"type": "relation",
"relation": "oneToOne",
"target": "api::game.game",
"mappedBy": "documentary"
},
"chapters": {
"type": "relation",
"relation": "oneToMany",
"target": "api::chapter.chapter",
"mappedBy": "documentary"
},
"publishedAt": {
"type": "datetime"
}
}
}
```
**Step 3: Create Chapter content type**
Create directory structure: `apps/cms/src/api/chapter/content-types/chapter/`
`apps/cms/src/api/chapter/content-types/chapter/schema.json`:
```json
{
"kind": "collectionType",
"collectionName": "chapters",
"info": {
"singularName": "chapter",
"pluralName": "chapters",
"displayName": "Chapter",
"description": "A chapter of a documentary"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"pluginOptions": {
"i18n": { "localized": true }
}
},
"content": {
"type": "richtext",
"required": true,
"pluginOptions": {
"i18n": { "localized": true }
}
},
"audioFile": {
"type": "media",
"multiple": false,
"allowedTypes": ["audios"]
},
"audioDuration": {
"type": "integer"
},
"order": {
"type": "integer",
"required": true,
"default": 0
},
"coverImage": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"documentary": {
"type": "relation",
"relation": "manyToOne",
"target": "api::documentary.documentary",
"inversedBy": "chapters"
}
}
}
```
**Step 4: Create route, controller, and service stubs for each API**
For each of `game`, `documentary`, `chapter`, create:
`apps/cms/src/api/{name}/routes/{name}.ts`:
```typescript
import { factories } from "@strapi/strapi";
export default factories.createCoreRouter("api::{name}.{name}");
```
`apps/cms/src/api/{name}/controllers/{name}.ts`:
```typescript
import { factories } from "@strapi/strapi";
export default factories.createCoreController("api::{name}.{name}");
```
`apps/cms/src/api/{name}/services/{name}.ts`:
```typescript
import { factories } from "@strapi/strapi";
export default factories.createCoreService("api::{name}.{name}");
```
**Step 5: Commit**
```bash
git add apps/cms/src/api/
git commit -m "feat: define Game, Documentary, Chapter content types in Strapi"
```
---
## Phase 3: Next.js Frontend Setup
### Task 5: Scaffold Next.js app
**Files:**
- Create: `apps/web/` (Next.js scaffold)
**Step 1: Create Next.js project**
Run: `npx create-next-app@latest apps/web --typescript --tailwind --app --src-dir --eslint --no-import-alias`
**Step 2: Update apps/web/package.json** — set name to `@afterlife/web`
**Step 3: Add shared package as dependency**
Add to `apps/web/package.json` dependencies:
```json
"@afterlife/shared": "*"
```
**Step 4: Install dependencies from root**
Run (from project root): `npm install`
**Step 5: Verify it builds**
Run: `cd apps/web && npm run build`
Expected: Build succeeds
**Step 6: Commit**
```bash
git add apps/web/
git commit -m "feat: scaffold Next.js 15 frontend app"
```
---
### Task 6: Configure i18n with next-intl
**Files:**
- Modify: `apps/web/package.json` (add next-intl)
- Create: `apps/web/src/i18n/request.ts`
- Create: `apps/web/src/i18n/routing.ts`
- Create: `apps/web/src/messages/es.json`
- Create: `apps/web/src/messages/en.json`
- Create: `apps/web/src/middleware.ts`
- Modify: `apps/web/src/app/layout.tsx` → move to `apps/web/src/app/[locale]/layout.tsx`
- Modify: `apps/web/src/app/page.tsx` → move to `apps/web/src/app/[locale]/page.tsx`
**Step 1: Install next-intl**
Run: `cd apps/web && npm install next-intl`
**Step 2: Create i18n routing config**
`apps/web/src/i18n/routing.ts`:
```typescript
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["es", "en"],
defaultLocale: "es",
});
```
**Step 3: Create i18n request config**
`apps/web/src/i18n/request.ts`:
```typescript
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
```
**Step 4: Create message files**
`apps/web/src/messages/es.json`:
```json
{
"nav": {
"home": "Inicio",
"catalog": "Catálogo",
"about": "Sobre Nosotros",
"donate": "Donaciones"
},
"home": {
"hero_title": "Project Afterlife",
"hero_subtitle": "Preservando juegos online que merecen una segunda vida",
"latest_games": "Últimos juegos restaurados",
"view_all": "Ver todos",
"donate_cta": "Apoya la preservación"
},
"catalog": {
"title": "Catálogo de Juegos",
"filter_genre": "Género",
"filter_status": "Estado del servidor",
"all": "Todos",
"no_results": "No se encontraron juegos"
},
"game": {
"released": "Lanzado",
"shutdown": "Cerrado",
"developer": "Desarrolladora",
"publisher": "Distribuidora",
"server_status": "Estado del servidor",
"play_now": "Jugar ahora",
"view_documentary": "Ver documental",
"status_online": "En línea",
"status_maintenance": "Mantenimiento",
"status_coming_soon": "Próximamente"
},
"documentary": {
"chapters": "Capítulos",
"listen": "Escuchar",
"reading_progress": "Progreso de lectura"
},
"audio": {
"play": "Reproducir",
"pause": "Pausar",
"speed": "Velocidad",
"chapter_mode": "Por capítulo",
"continuous_mode": "Continuo"
},
"about": {
"title": "Sobre Nosotros",
"mission": "Nuestra Misión",
"team": "El Equipo",
"contribute": "Cómo Contribuir"
},
"donate": {
"title": "Donaciones",
"description": "Project Afterlife se financia exclusivamente con donaciones. Tu apoyo mantiene vivos estos juegos.",
"patreon": "Donar en Patreon",
"kofi": "Donar en Ko-fi",
"transparency": "Transparencia de fondos"
},
"footer": {
"rights": "Project Afterlife. Preservando la historia del gaming.",
"language": "Idioma"
}
}
```
`apps/web/src/messages/en.json`:
```json
{
"nav": {
"home": "Home",
"catalog": "Catalog",
"about": "About Us",
"donate": "Donations"
},
"home": {
"hero_title": "Project Afterlife",
"hero_subtitle": "Preserving online games that deserve a second life",
"latest_games": "Latest restored games",
"view_all": "View all",
"donate_cta": "Support preservation"
},
"catalog": {
"title": "Game Catalog",
"filter_genre": "Genre",
"filter_status": "Server status",
"all": "All",
"no_results": "No games found"
},
"game": {
"released": "Released",
"shutdown": "Shutdown",
"developer": "Developer",
"publisher": "Publisher",
"server_status": "Server status",
"play_now": "Play now",
"view_documentary": "View documentary",
"status_online": "Online",
"status_maintenance": "Maintenance",
"status_coming_soon": "Coming soon"
},
"documentary": {
"chapters": "Chapters",
"listen": "Listen",
"reading_progress": "Reading progress"
},
"audio": {
"play": "Play",
"pause": "Pause",
"speed": "Speed",
"chapter_mode": "By chapter",
"continuous_mode": "Continuous"
},
"about": {
"title": "About Us",
"mission": "Our Mission",
"team": "The Team",
"contribute": "How to Contribute"
},
"donate": {
"title": "Donations",
"description": "Project Afterlife is funded exclusively by donations. Your support keeps these games alive.",
"patreon": "Donate on Patreon",
"kofi": "Donate on Ko-fi",
"transparency": "Fund transparency"
},
"footer": {
"rights": "Project Afterlife. Preserving gaming history.",
"language": "Language"
}
}
```
**Step 5: Create middleware for locale routing**
`apps/web/src/middleware.ts`:
```typescript
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
```
**Step 6: Move layout and page into [locale] directory**
Move `apps/web/src/app/layout.tsx``apps/web/src/app/[locale]/layout.tsx`
Move `apps/web/src/app/page.tsx``apps/web/src/app/[locale]/page.tsx`
Update `apps/web/src/app/[locale]/layout.tsx`:
```typescript
import type { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
import "./globals.css";
export const metadata: Metadata = {
title: "Project Afterlife",
description: "Preserving online games that deserve a second life",
};
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as any)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
```
Also move `globals.css` to `apps/web/src/app/[locale]/globals.css`.
**Step 7: Update next.config**
Modify `apps/web/next.config.ts`:
```typescript
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig = {};
export default withNextIntl(nextConfig);
```
**Step 8: Verify it builds**
Run: `cd apps/web && npm run build`
Expected: Build succeeds with locale routing
**Step 9: Commit**
```bash
git add apps/web/
git commit -m "feat: configure next-intl i18n with ES/EN locales"
```
---
### Task 7: Create Strapi API client
**Files:**
- Create: `apps/web/src/lib/strapi.ts`
- Create: `apps/web/src/lib/api.ts`
- Create: `apps/web/.env.local`
**Step 1: Create .env.local**
`apps/web/.env.local`:
```
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-api-token-here
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
```
**Step 2: Create base Strapi client**
`apps/web/src/lib/strapi.ts`:
```typescript
const STRAPI_URL = process.env.STRAPI_URL || "http://localhost:1337";
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
interface FetchOptions {
path: string;
params?: Record<string, string>;
locale?: string;
}
export async function strapiGet<T>({ path, params, locale }: FetchOptions): Promise<T> {
const url = new URL(`/api${path}`, STRAPI_URL);
if (locale) url.searchParams.set("locale", locale);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
const headers: HeadersInit = { "Content-Type": "application/json" };
if (STRAPI_TOKEN) {
headers.Authorization = `Bearer ${STRAPI_TOKEN}`;
}
const res = await fetch(url.toString(), {
headers,
next: { revalidate: 60 },
});
if (!res.ok) {
throw new Error(`Strapi error: ${res.status} ${res.statusText}`);
}
return res.json();
}
```
**Step 3: Create API functions**
`apps/web/src/lib/api.ts`:
```typescript
import type {
Game,
Documentary,
Chapter,
StrapiListResponse,
StrapiResponse,
} from "@afterlife/shared";
import { strapiGet } from "./strapi";
export async function getGames(locale: string): Promise<StrapiListResponse<Game>> {
return strapiGet({
path: "/games",
locale,
params: {
"populate[coverImage]": "*",
"populate[documentary]": "*",
"sort": "createdAt:desc",
},
});
}
export async function getGameBySlug(slug: string, locale: string): Promise<StrapiResponse<Game>> {
return strapiGet({
path: `/games`,
locale,
params: {
"filters[slug][$eq]": slug,
"populate[coverImage]": "*",
"populate[screenshots]": "*",
"populate[documentary][populate][chapters][populate]": "*",
},
});
}
export async function getDocumentaryByGameSlug(
slug: string,
locale: string
): Promise<Documentary | null> {
const gameRes = await getGameBySlug(slug, locale);
const game = Array.isArray(gameRes.data) ? gameRes.data[0] : gameRes.data;
return game?.documentary ?? null;
}
export async function getChapter(
chapterId: number,
locale: string
): Promise<StrapiResponse<Chapter>> {
return strapiGet({
path: `/chapters/${chapterId}`,
locale,
params: {
"populate[audioFile]": "*",
"populate[coverImage]": "*",
},
});
}
```
**Step 4: Commit**
```bash
git add apps/web/src/lib/ apps/web/.env.local
git commit -m "feat: add Strapi API client and data fetching functions"
```
---
## Phase 4: Frontend Pages
### Task 8: Create shared layout components (Navbar + Footer)
**Files:**
- Create: `apps/web/src/components/layout/Navbar.tsx`
- Create: `apps/web/src/components/layout/Footer.tsx`
- Create: `apps/web/src/components/layout/LanguageSwitcher.tsx`
- Modify: `apps/web/src/app/[locale]/layout.tsx`
**Step 1: Install framer-motion**
Run: `cd apps/web && npm install framer-motion`
**Step 2: Create LanguageSwitcher component**
`apps/web/src/components/layout/LanguageSwitcher.tsx`:
```tsx
"use client";
import { useLocale, useTranslations } from "next-intl";
import { useRouter, usePathname } from "next/navigation";
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("footer");
function switchLocale(newLocale: string) {
const segments = pathname.split("/");
segments[1] = newLocale;
router.push(segments.join("/"));
}
return (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">{t("language")}:</span>
<button
onClick={() => switchLocale("es")}
className={`text-sm ${locale === "es" ? "text-white font-bold" : "text-gray-400 hover:text-white"}`}
>
ES
</button>
<span className="text-gray-600">|</span>
<button
onClick={() => switchLocale("en")}
className={`text-sm ${locale === "en" ? "text-white font-bold" : "text-gray-400 hover:text-white"}`}
>
EN
</button>
</div>
);
}
```
**Step 3: Create Navbar**
`apps/web/src/components/layout/Navbar.tsx`:
```tsx
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { LanguageSwitcher } from "./LanguageSwitcher";
export function Navbar() {
const t = useTranslations("nav");
const locale = useLocale();
const links = [
{ href: `/${locale}`, label: t("home") },
{ href: `/${locale}/catalog`, label: t("catalog") },
{ href: `/${locale}/about`, label: t("about") },
{ href: `/${locale}/donate`, label: t("donate") },
];
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm border-b border-white/10">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<Link href={`/${locale}`} className="text-xl font-bold text-white tracking-tight">
Project Afterlife
</Link>
<div className="flex items-center gap-6">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-sm text-gray-300 hover:text-white transition-colors"
>
{link.label}
</Link>
))}
<LanguageSwitcher />
</div>
</div>
</nav>
);
}
```
**Step 4: Create Footer**
`apps/web/src/components/layout/Footer.tsx`:
```tsx
import { useTranslations } from "next-intl";
export function Footer() {
const t = useTranslations("footer");
return (
<footer className="bg-black border-t border-white/10 py-8">
<div className="max-w-7xl mx-auto px-4 text-center">
<p className="text-sm text-gray-500">{t("rights")}</p>
</div>
</footer>
);
}
```
**Step 5: Update layout to include Navbar and Footer**
Update `apps/web/src/app/[locale]/layout.tsx` body to include:
```tsx
<body className="bg-gray-950 text-white min-h-screen flex flex-col">
<NextIntlClientProvider messages={messages}>
<Navbar />
<main className="flex-1 pt-16">{children}</main>
<Footer />
</NextIntlClientProvider>
</body>
```
**Step 6: Commit**
```bash
git add apps/web/src/components/ apps/web/src/app/
git commit -m "feat: add Navbar, Footer, and LanguageSwitcher layout components"
```
---
### Task 9: Build Landing Page
**Files:**
- Modify: `apps/web/src/app/[locale]/page.tsx`
- Create: `apps/web/src/components/home/HeroSection.tsx`
- Create: `apps/web/src/components/home/LatestGames.tsx`
- Create: `apps/web/src/components/home/DonationCTA.tsx`
- Create: `apps/web/src/components/shared/GameCard.tsx`
**Step 1: Create GameCard component** (shared, used in catalog too)
`apps/web/src/components/shared/GameCard.tsx`:
```tsx
import Link from "next/link";
import Image from "next/image";
import type { Game } from "@afterlife/shared";
interface GameCardProps {
game: Game;
locale: string;
}
export function GameCard({ game, locale }: GameCardProps) {
const statusColors = {
online: "bg-green-500",
maintenance: "bg-yellow-500",
coming_soon: "bg-blue-500",
};
return (
<Link href={`/${locale}/games/${game.slug}`} className="group block">
<div className="relative overflow-hidden rounded-lg bg-gray-900 border border-white/5 hover:border-white/20 transition-all">
{game.coverImage && (
<div className="relative aspect-[16/9] overflow-hidden">
<Image
src={game.coverImage.url}
alt={game.coverImage.alternativeText || game.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-transparent" />
</div>
)}
<div className="p-4">
<div className="flex items-center gap-2 mb-2">
<span className={`w-2 h-2 rounded-full ${statusColors[game.serverStatus]}`} />
<span className="text-xs text-gray-400 uppercase tracking-wider">{game.genre}</span>
</div>
<h3 className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
{game.title}
</h3>
<p className="text-sm text-gray-500 mt-1">
{game.releaseYear} {game.shutdownYear}
</p>
</div>
</div>
</Link>
);
}
```
**Step 2: Create HeroSection**
`apps/web/src/components/home/HeroSection.tsx`:
```tsx
"use client";
import { useTranslations } from "next-intl";
import { motion } from "framer-motion";
import Link from "next/link";
import { useLocale } from "next-intl";
export function HeroSection() {
const t = useTranslations("home");
const locale = useLocale();
return (
<section className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-blue-950/20 via-gray-950 to-gray-950" />
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="text-5xl md:text-7xl font-bold tracking-tight mb-6"
>
{t("hero_title")}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="text-xl md:text-2xl text-gray-400 mb-10"
>
{t("hero_subtitle")}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
className="flex gap-4 justify-center"
>
<Link
href={`/${locale}/catalog`}
className="px-8 py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition-colors"
>
{t("view_all")}
</Link>
<Link
href={`/${locale}/donate`}
className="px-8 py-3 border border-white/20 text-white font-semibold rounded-lg hover:bg-white/10 transition-colors"
>
{t("donate_cta")}
</Link>
</motion.div>
</div>
</section>
);
}
```
**Step 3: Create LatestGames**
`apps/web/src/components/home/LatestGames.tsx`:
```tsx
import { useTranslations } from "next-intl";
import Link from "next/link";
import type { Game } from "@afterlife/shared";
import { GameCard } from "../shared/GameCard";
interface LatestGamesProps {
games: Game[];
locale: string;
}
export function LatestGames({ games, locale }: LatestGamesProps) {
const t = useTranslations("home");
if (games.length === 0) return null;
return (
<section className="max-w-7xl mx-auto px-4 py-20">
<div className="flex items-center justify-between mb-10">
<h2 className="text-3xl font-bold">{t("latest_games")}</h2>
<Link
href={`/${locale}/catalog`}
className="text-sm text-gray-400 hover:text-white transition-colors"
>
{t("view_all")}
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{games.slice(0, 6).map((game) => (
<GameCard key={game.id} game={game} locale={locale} />
))}
</div>
</section>
);
}
```
**Step 4: Create DonationCTA**
`apps/web/src/components/home/DonationCTA.tsx`:
```tsx
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useLocale } from "next-intl";
export function DonationCTA() {
const t = useTranslations("donate");
const locale = useLocale();
return (
<section className="bg-gradient-to-r from-blue-950/50 to-purple-950/50 py-20">
<div className="max-w-3xl mx-auto px-4 text-center">
<h2 className="text-3xl font-bold mb-4">{t("title")}</h2>
<p className="text-gray-400 mb-8">{t("description")}</p>
<Link
href={`/${locale}/donate`}
className="inline-block px-8 py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition-colors"
>
{t("patreon")}
</Link>
</div>
</section>
);
}
```
**Step 5: Wire up the landing page**
`apps/web/src/app/[locale]/page.tsx`:
```tsx
import { getGames } from "@/lib/api";
import { HeroSection } from "@/components/home/HeroSection";
import { LatestGames } from "@/components/home/LatestGames";
import { DonationCTA } from "@/components/home/DonationCTA";
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
let games = [];
try {
const res = await getGames(locale);
games = res.data;
} catch {
// Strapi not running yet — render page without games
}
return (
<>
<HeroSection />
<LatestGames games={games} locale={locale} />
<DonationCTA />
</>
);
}
```
**Step 6: Commit**
```bash
git add apps/web/src/
git commit -m "feat: build landing page with hero, latest games, and donation CTA"
```
---
### Task 10: Build Game Catalog Page
**Files:**
- Create: `apps/web/src/app/[locale]/catalog/page.tsx`
- Create: `apps/web/src/components/catalog/CatalogFilters.tsx`
- Create: `apps/web/src/components/catalog/CatalogGrid.tsx`
**Step 1: Create CatalogFilters**
`apps/web/src/components/catalog/CatalogFilters.tsx`:
```tsx
"use client";
import { useTranslations } from "next-intl";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import type { Genre, ServerStatus } from "@afterlife/shared";
const GENRES: Genre[] = ["MMORPG", "FPS", "Casual", "Strategy", "Sports", "Other"];
const STATUSES: ServerStatus[] = ["online", "maintenance", "coming_soon"];
export function CatalogFilters() {
const t = useTranslations("catalog");
const tGame = useTranslations("game");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const currentGenre = searchParams.get("genre") || "";
const currentStatus = searchParams.get("status") || "";
function setFilter(key: string, value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`${pathname}?${params.toString()}`);
}
return (
<div className="flex flex-wrap gap-4 mb-8">
<select
value={currentGenre}
onChange={(e) => setFilter("genre", e.target.value)}
className="bg-gray-900 border border-white/10 rounded-lg px-4 py-2 text-sm text-white"
>
<option value="">{t("filter_genre")}: {t("all")}</option>
{GENRES.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
<select
value={currentStatus}
onChange={(e) => setFilter("status", e.target.value)}
className="bg-gray-900 border border-white/10 rounded-lg px-4 py-2 text-sm text-white"
>
<option value="">{t("filter_status")}: {t("all")}</option>
{STATUSES.map((s) => (
<option key={s} value={s}>
{tGame(`status_${s}`)}
</option>
))}
</select>
</div>
);
}
```
**Step 2: Create CatalogGrid**
`apps/web/src/components/catalog/CatalogGrid.tsx`:
```tsx
import { useTranslations } from "next-intl";
import type { Game } from "@afterlife/shared";
import { GameCard } from "../shared/GameCard";
interface CatalogGridProps {
games: Game[];
locale: string;
}
export function CatalogGrid({ games, locale }: CatalogGridProps) {
const t = useTranslations("catalog");
if (games.length === 0) {
return (
<div className="text-center py-20">
<p className="text-gray-500 text-lg">{t("no_results")}</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{games.map((game) => (
<GameCard key={game.id} game={game} locale={locale} />
))}
</div>
);
}
```
**Step 3: Create catalog page**
`apps/web/src/app/[locale]/catalog/page.tsx`:
```tsx
import { Suspense } from "react";
import { getGames } from "@/lib/api";
import { useTranslations } from "next-intl";
import { CatalogFilters } from "@/components/catalog/CatalogFilters";
import { CatalogGrid } from "@/components/catalog/CatalogGrid";
export default async function CatalogPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
let games = [];
try {
const res = await getGames(locale);
games = res.data;
} catch {
// Strapi not running
}
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">
{locale === "es" ? "Catálogo de Juegos" : "Game Catalog"}
</h1>
<Suspense>
<CatalogFilters />
</Suspense>
<CatalogGrid games={games} locale={locale} />
</div>
);
}
```
**Step 4: Commit**
```bash
git add apps/web/src/
git commit -m "feat: build game catalog page with filters and grid"
```
---
### Task 11: Build Game Detail Page
**Files:**
- Create: `apps/web/src/app/[locale]/games/[slug]/page.tsx`
- Create: `apps/web/src/components/game/GameHeader.tsx`
- Create: `apps/web/src/components/game/GameInfo.tsx`
- Create: `apps/web/src/components/game/ScreenshotGallery.tsx`
**Step 1: Create GameHeader**
`apps/web/src/components/game/GameHeader.tsx`:
```tsx
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>
);
}
```
**Step 2: Create GameInfo**
`apps/web/src/components/game/GameInfo.tsx`:
```tsx
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>
);
}
```
**Step 3: Create ScreenshotGallery**
`apps/web/src/components/game/ScreenshotGallery.tsx`:
```tsx
"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>
);
}
```
**Step 4: Create game detail page**
`apps/web/src/app/[locale]/games/[slug]/page.tsx`:
```tsx
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>
</>
);
}
```
**Step 5: Commit**
```bash
git add apps/web/src/
git commit -m "feat: build game detail page with header, info panel, and screenshot gallery"
```
---
### Task 12: Build Interactive Documentary Page
**Files:**
- Create: `apps/web/src/app/[locale]/games/[slug]/documentary/page.tsx`
- Create: `apps/web/src/components/documentary/DocumentaryLayout.tsx`
- Create: `apps/web/src/components/documentary/ChapterNav.tsx`
- Create: `apps/web/src/components/documentary/ChapterContent.tsx`
- Create: `apps/web/src/components/documentary/AudioPlayer.tsx`
- Create: `apps/web/src/components/documentary/ReadingProgress.tsx`
- Create: `apps/web/src/hooks/useAudioPlayer.ts`
**Step 1: Install howler.js**
Run: `cd apps/web && npm install howler && npm install -D @types/howler`
**Step 2: Create useAudioPlayer hook**
`apps/web/src/hooks/useAudioPlayer.ts`:
```typescript
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { Howl } from "howler";
interface AudioTrack {
id: number;
title: string;
url: string;
duration: number;
}
export function useAudioPlayer() {
const [tracks, setTracks] = useState<AudioTrack[]>([]);
const [currentTrackIndex, setCurrentTrackIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [playbackRate, setPlaybackRate] = useState(1);
const [continuousMode, setContinuousMode] = useState(false);
const howlRef = useRef<Howl | null>(null);
const animFrameRef = useRef<number>(0);
const currentTrack = tracks[currentTrackIndex] ?? null;
const destroyHowl = useCallback(() => {
if (howlRef.current) {
howlRef.current.unload();
howlRef.current = null;
}
cancelAnimationFrame(animFrameRef.current);
}, []);
const loadTrack = useCallback(
(index: number) => {
if (!tracks[index]) return;
destroyHowl();
const howl = new Howl({
src: [tracks[index].url],
html5: true,
rate: playbackRate,
onplay: () => {
setIsPlaying(true);
const updateProgress = () => {
if (howl.playing()) {
setProgress(howl.seek() as number);
animFrameRef.current = requestAnimationFrame(updateProgress);
}
};
animFrameRef.current = requestAnimationFrame(updateProgress);
},
onpause: () => setIsPlaying(false),
onstop: () => setIsPlaying(false),
onend: () => {
setIsPlaying(false);
if (continuousMode && index < tracks.length - 1) {
setCurrentTrackIndex(index + 1);
}
},
onload: () => {
setDuration(howl.duration());
},
});
howlRef.current = howl;
setCurrentTrackIndex(index);
setProgress(0);
},
[tracks, playbackRate, continuousMode, destroyHowl]
);
const play = useCallback(() => howlRef.current?.play(), []);
const pause = useCallback(() => howlRef.current?.pause(), []);
const toggle = useCallback(() => {
if (isPlaying) pause();
else play();
}, [isPlaying, play, pause]);
const seek = useCallback((seconds: number) => {
howlRef.current?.seek(seconds);
setProgress(seconds);
}, []);
const changeRate = useCallback(
(rate: number) => {
setPlaybackRate(rate);
howlRef.current?.rate(rate);
},
[]
);
const goToTrack = useCallback(
(index: number) => {
loadTrack(index);
play();
},
[loadTrack, play]
);
useEffect(() => {
return () => destroyHowl();
}, [destroyHowl]);
return {
tracks,
setTracks,
currentTrack,
currentTrackIndex,
isPlaying,
progress,
duration,
playbackRate,
continuousMode,
setContinuousMode,
loadTrack,
play,
pause,
toggle,
seek,
changeRate,
goToTrack,
};
}
```
**Step 3: Create ChapterNav**
`apps/web/src/components/documentary/ChapterNav.tsx`:
```tsx
"use client";
import type { Chapter } from "@afterlife/shared";
import { useTranslations } from "next-intl";
interface ChapterNavProps {
chapters: Chapter[];
activeChapterId: number;
onSelectChapter: (id: number, index: number) => void;
}
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">
{t("chapters")}
</h3>
<ol className="space-y-1">
{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 ${
chapter.id === activeChapterId
? "bg-blue-600/20 text-blue-400 font-medium"
: "text-gray-400 hover:text-white hover:bg-white/5"
}`}
>
<span className="text-xs text-gray-600 mr-2">{index + 1}.</span>
{chapter.title}
</button>
</li>
))}
</ol>
</div>
</nav>
);
}
```
**Step 4: Create ChapterContent**
`apps/web/src/components/documentary/ChapterContent.tsx`:
```tsx
import Image from "next/image";
import type { Chapter } from "@afterlife/shared";
interface ChapterContentProps {
chapter: Chapter;
}
export function ChapterContent({ chapter }: ChapterContentProps) {
return (
<article className="max-w-3xl">
{chapter.coverImage && (
<div className="relative aspect-video rounded-lg overflow-hidden mb-8">
<Image
src={chapter.coverImage.url}
alt={chapter.coverImage.alternativeText || chapter.title}
fill
className="object-cover"
/>
</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 }}
/>
</article>
);
}
```
**Step 5: Create AudioPlayer**
`apps/web/src/components/documentary/AudioPlayer.tsx`:
```tsx
"use client";
import { useTranslations } from "next-intl";
interface AudioPlayerProps {
trackTitle: string | null;
isPlaying: boolean;
progress: number;
duration: number;
playbackRate: number;
continuousMode: boolean;
onToggle: () => void;
onSeek: (seconds: number) => void;
onChangeRate: (rate: number) => void;
onToggleContinuous: () => void;
}
const RATES = [0.5, 0.75, 1, 1.25, 1.5, 2];
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function AudioPlayer({
trackTitle,
isPlaying,
progress,
duration,
playbackRate,
continuousMode,
onToggle,
onSeek,
onChangeRate,
onToggleContinuous,
}: AudioPlayerProps) {
const t = useTranslations("audio");
if (!trackTitle) return null;
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900/95 backdrop-blur-sm border-t border-white/10">
<div className="max-w-7xl mx-auto px-4 py-3">
{/* Progress bar */}
<div
className="w-full h-1 bg-gray-700 rounded-full mb-3 cursor-pointer"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
onSeek(ratio * duration);
}}
>
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="flex items-center gap-4">
{/* Play/Pause */}
<button
onClick={onToggle}
className="w-10 h-10 flex items-center justify-center bg-white rounded-full text-black hover:bg-gray-200 transition-colors"
aria-label={isPlaying ? t("pause") : t("play")}
>
{isPlaying ? "⏸" : "▶"}
</button>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{trackTitle}</p>
<p className="text-xs text-gray-500">
{formatTime(progress)} / {formatTime(duration)}
</p>
</div>
{/* Speed selector */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{t("speed")}:</span>
<select
value={playbackRate}
onChange={(e) => onChangeRate(Number(e.target.value))}
className="bg-gray-800 border border-white/10 rounded px-2 py-1 text-xs text-white"
>
{RATES.map((r) => (
<option key={r} value={r}>
{r}x
</option>
))}
</select>
</div>
{/* Continuous mode toggle */}
<button
onClick={onToggleContinuous}
className={`text-xs px-3 py-1 rounded border transition-colors ${
continuousMode
? "border-blue-500 text-blue-400"
: "border-white/10 text-gray-500 hover:text-white"
}`}
>
{continuousMode ? t("continuous_mode") : t("chapter_mode")}
</button>
</div>
</div>
</div>
);
}
```
**Step 6: Create ReadingProgress**
`apps/web/src/components/documentary/ReadingProgress.tsx`:
```tsx
"use client";
import { useEffect, useState } from "react";
export function ReadingProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
function handleScroll() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
setProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<div className="fixed top-16 left-0 right-0 z-40 h-0.5 bg-gray-800">
<div
className="h-full bg-blue-500 transition-all duration-150"
style={{ width: `${progress}%` }}
/>
</div>
);
}
```
**Step 7: Create DocumentaryLayout (client component that orchestrates everything)**
`apps/web/src/components/documentary/DocumentaryLayout.tsx`:
```tsx
"use client";
import { useEffect, useState } from "react";
import type { Documentary, Chapter } from "@afterlife/shared";
import { ChapterNav } from "./ChapterNav";
import { ChapterContent } from "./ChapterContent";
import { AudioPlayer } from "./AudioPlayer";
import { ReadingProgress } from "./ReadingProgress";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
interface DocumentaryLayoutProps {
documentary: Documentary;
}
export function DocumentaryLayout({ documentary }: DocumentaryLayoutProps) {
const chapters = [...documentary.chapters].sort((a, b) => a.order - b.order);
const [activeChapter, setActiveChapter] = useState<Chapter>(chapters[0]);
const audio = useAudioPlayer();
useEffect(() => {
const audioTracks = chapters
.filter((ch) => ch.audioFile)
.map((ch) => ({
id: ch.id,
title: ch.title,
url: ch.audioFile!.url,
duration: ch.audioDuration ?? 0,
}));
audio.setTracks(audioTracks);
if (audioTracks.length > 0) {
audio.loadTrack(0);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSelectChapter(chapterId: number, index: number) {
const chapter = chapters.find((c) => c.id === chapterId);
if (chapter) {
setActiveChapter(chapter);
// Sync audio to this chapter's track
const trackIndex = audio.tracks.findIndex((t) => t.id === chapterId);
if (trackIndex !== -1) {
audio.goToTrack(trackIndex);
}
}
}
return (
<>
<ReadingProgress />
<div className="max-w-7xl mx-auto px-4 py-12 flex gap-8">
<ChapterNav
chapters={chapters}
activeChapterId={activeChapter.id}
onSelectChapter={handleSelectChapter}
/>
<div className="flex-1 pb-24">
<ChapterContent chapter={activeChapter} />
</div>
</div>
<AudioPlayer
trackTitle={audio.currentTrack?.title ?? null}
isPlaying={audio.isPlaying}
progress={audio.progress}
duration={audio.duration}
playbackRate={audio.playbackRate}
continuousMode={audio.continuousMode}
onToggle={audio.toggle}
onSeek={audio.seek}
onChangeRate={audio.changeRate}
onToggleContinuous={() => audio.setContinuousMode(!audio.continuousMode)}
/>
</>
);
}
```
**Step 8: Create documentary page**
`apps/web/src/app/[locale]/games/[slug]/documentary/page.tsx`:
```tsx
import { notFound } from "next/navigation";
import { getDocumentaryByGameSlug } from "@/lib/api";
import { DocumentaryLayout } from "@/components/documentary/DocumentaryLayout";
export default async function DocumentaryPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const documentary = await getDocumentaryByGameSlug(slug, locale);
if (!documentary || !documentary.chapters?.length) {
notFound();
}
return <DocumentaryLayout documentary={documentary} />;
}
```
**Step 9: Commit**
```bash
git add apps/web/src/
git commit -m "feat: build interactive documentary page with audio player and chapter navigation"
```
---
### Task 13: Build About and Donate pages
**Files:**
- Create: `apps/web/src/app/[locale]/about/page.tsx`
- Create: `apps/web/src/app/[locale]/donate/page.tsx`
**Step 1: Create About page**
`apps/web/src/app/[locale]/about/page.tsx`:
```tsx
import { useTranslations } from "next-intl";
export default function AboutPage() {
const t = useTranslations("about");
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-12">{t("title")}</h1>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">{t("mission")}</h2>
<div className="prose prose-invert prose-lg max-w-none">
<p>
Project Afterlife nace de la convicción de que los juegos online que marcaron a
generaciones de jugadores no deberían desaparecer cuando sus servidores se apagan.
Somos un equipo dedicado a preservar estos mundos virtuales, restaurando sus servidores
y documentando su historia para que nunca sean olvidados.
</p>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-4">{t("team")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Team members — to be populated from CMS in the future */}
<div className="bg-gray-900 rounded-lg p-6 border border-white/5">
<p className="text-gray-500 text-sm">Team members coming soon.</p>
</div>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">{t("contribute")}</h2>
<div className="prose prose-invert prose-lg max-w-none">
<p>
Si tienes experiencia con servidores de juegos, desarrollo web, narración, o
simplemente quieres ayudar, contacta con nosotros.
</p>
</div>
</section>
</div>
);
}
```
**Step 2: Create Donate page**
`apps/web/src/app/[locale]/donate/page.tsx`:
```tsx
import { useTranslations } from "next-intl";
export default function DonatePage() {
const t = useTranslations("donate");
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-6">{t("title")}</h1>
<p className="text-lg text-gray-400 mb-12">{t("description")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16">
<a
href="https://patreon.com/projectafterlife"
target="_blank"
rel="noopener noreferrer"
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-orange-500/50 transition-colors text-center"
>
<h3 className="text-2xl font-bold mb-2 text-orange-400">Patreon</h3>
<p className="text-gray-400 text-sm mb-4">Donaciones recurrentes mensuales</p>
<span className="inline-block px-6 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg font-medium transition-colors">
{t("patreon")}
</span>
</a>
<a
href="https://ko-fi.com/projectafterlife"
target="_blank"
rel="noopener noreferrer"
className="block bg-gray-900 rounded-lg p-8 border border-white/5 hover:border-sky-500/50 transition-colors text-center"
>
<h3 className="text-2xl font-bold mb-2 text-sky-400">Ko-fi</h3>
<p className="text-gray-400 text-sm mb-4">Donaciones puntuales</p>
<span className="inline-block px-6 py-2 bg-sky-600 hover:bg-sky-700 text-white rounded-lg font-medium transition-colors">
{t("kofi")}
</span>
</a>
</div>
<section>
<h2 className="text-2xl font-semibold mb-4">{t("transparency")}</h2>
<div className="prose prose-invert max-w-none">
<p>
Cada donación se destina al mantenimiento de servidores, costes de hosting,
y equipamiento para la grabación de los audiolibros narrativos. Publicamos
un desglose mensual de gastos.
</p>
</div>
</section>
</div>
);
}
```
**Step 3: Commit**
```bash
git add apps/web/src/app/
git commit -m "feat: add About and Donate pages"
```
---
## Phase 5: Docker & Deployment
### Task 14: Create Docker Compose setup
**Files:**
- Create: `docker/docker-compose.yml`
- Create: `docker/nginx/nginx.conf`
- Create: `apps/web/Dockerfile`
- Create: `apps/cms/Dockerfile`
- Create: `docker/.env.example`
**Step 1: Create docker-compose.yml**
`docker/docker-compose.yml`:
```yaml
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${DATABASE_NAME:-afterlife}
POSTGRES_USER: ${DATABASE_USERNAME:-afterlife}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-afterlife}
ports:
- "5432:5432"
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-afterlife}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-afterlife123}
ports:
- "9000:9000"
- "9001:9001"
cms:
build:
context: ../apps/cms
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- postgres
- minio
environment:
HOST: 0.0.0.0
PORT: 1337
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_NAME: ${DATABASE_NAME:-afterlife}
DATABASE_USERNAME: ${DATABASE_USERNAME:-afterlife}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-afterlife}
APP_KEYS: ${APP_KEYS}
API_TOKEN_SALT: ${API_TOKEN_SALT}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
JWT_SECRET: ${JWT_SECRET}
ports:
- "1337:1337"
web:
build:
context: ../
dockerfile: apps/web/Dockerfile
restart: unless-stopped
depends_on:
- cms
environment:
STRAPI_URL: http://cms:1337
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}
NEXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL:-http://localhost:1337}
ports:
- "3000:3000"
nginx:
image: nginx:alpine
restart: unless-stopped
depends_on:
- web
- cms
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- certbot_certs:/etc/letsencrypt:ro
- certbot_www:/var/www/certbot:ro
certbot:
image: certbot/certbot
volumes:
- certbot_certs:/etc/letsencrypt
- certbot_www:/var/www/certbot
volumes:
postgres_data:
minio_data:
certbot_certs:
certbot_www:
```
**Step 2: Create nginx.conf**
`docker/nginx/nginx.conf`:
```nginx
events {
worker_connections 1024;
}
http {
upstream web {
server web:3000;
}
upstream cms {
server cms:1337;
}
# Redirect HTTP to HTTPS (uncomment when SSL is ready)
# server {
# listen 80;
# server_name yourdomain.com;
# location /.well-known/acme-challenge/ {
# root /var/www/certbot;
# }
# return 301 https://$host$request_uri;
# }
server {
listen 80;
server_name _;
client_max_body_size 100M;
# Frontend
location / {
proxy_pass http://web;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Strapi API
location /api/ {
proxy_pass http://cms;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Strapi Admin
location /admin {
proxy_pass http://cms;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Strapi uploads
location /uploads/ {
proxy_pass http://cms;
proxy_set_header Host $host;
}
# Certbot challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
}
```
**Step 3: Create Strapi Dockerfile**
`apps/cms/Dockerfile`:
```dockerfile
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=base /app ./
EXPOSE 1337
CMD ["npm", "run", "start"]
```
**Step 4: Create Next.js Dockerfile**
`apps/web/Dockerfile`:
```dockerfile
FROM node:20-alpine AS base
WORKDIR /app
# Copy root workspace files
COPY package.json package-lock.json* turbo.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
RUN npm ci
# Copy source
COPY packages/shared/ ./packages/shared/
COPY apps/web/ ./apps/web/
WORKDIR /app/apps/web
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app/apps/web
COPY --from=base /app/apps/web/.next ./.next
COPY --from=base /app/apps/web/public ./public
COPY --from=base /app/apps/web/package.json ./
COPY --from=base /app/apps/web/node_modules ./node_modules
COPY --from=base /app/node_modules /app/node_modules
COPY --from=base /app/packages /app/packages
EXPOSE 3000
CMD ["npm", "start"]
```
**Step 5: Create .env.example**
`docker/.env.example`:
```
# Database
DATABASE_NAME=afterlife
DATABASE_USERNAME=afterlife
DATABASE_PASSWORD=change_me_in_production
# Strapi
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=change_me
ADMIN_JWT_SECRET=change_me
TRANSFER_TOKEN_SALT=change_me
JWT_SECRET=change_me
STRAPI_API_TOKEN=your_api_token_after_first_boot
# MinIO
MINIO_ROOT_USER=afterlife
MINIO_ROOT_PASSWORD=change_me_in_production
# Public URL (for frontend image/media URLs)
PUBLIC_STRAPI_URL=http://yourdomain.com
```
**Step 6: Commit**
```bash
git add docker/ apps/web/Dockerfile apps/cms/Dockerfile
git commit -m "feat: add Docker Compose setup with Nginx, PostgreSQL, MinIO"
```
---
### Task 15: Create GitHub Actions CI/CD
**Files:**
- Create: `.github/workflows/deploy.yml`
**Step 1: Create deploy workflow**
`.github/workflows/deploy.yml`:
```yaml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/project-afterlife
git pull origin main
cd docker
docker compose build
docker compose up -d
docker compose exec web npm run build
docker compose restart web
```
**Step 2: Commit**
```bash
git add .github/
git commit -m "feat: add GitHub Actions CI/CD deploy workflow"
```
---
## Phase 6: Final Polish
### Task 16: Add SEO metadata and Open Graph
**Files:**
- Modify: `apps/web/src/app/[locale]/layout.tsx` — add base metadata
- Create: `apps/web/src/lib/metadata.ts` — helper for per-page metadata
**Step 1: Create metadata helper**
`apps/web/src/lib/metadata.ts`:
```typescript
import type { Metadata } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://projectafterlife.dev";
export function createMetadata({
title,
description,
image,
path = "",
}: {
title: string;
description: string;
image?: string;
path?: string;
}): Metadata {
const url = `${BASE_URL}${path}`;
return {
title: `${title} | Project Afterlife`,
description,
openGraph: {
title,
description,
url,
siteName: "Project Afterlife",
images: image ? [{ url: image, width: 1200, height: 630 }] : [],
type: "website",
},
twitter: {
card: "summary_large_image",
title,
description,
images: image ? [image] : [],
},
};
}
```
**Step 2: Add base metadata to layout**
Update `apps/web/src/app/[locale]/layout.tsx` metadata:
```typescript
export const metadata: Metadata = {
title: {
default: "Project Afterlife",
template: "%s | Project Afterlife",
},
description: "Preserving online games that deserve a second life",
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://projectafterlife.dev"),
};
```
**Step 3: Commit**
```bash
git add apps/web/src/
git commit -m "feat: add SEO metadata and Open Graph helpers"
```
---
### Task 17: Configure remote repository and initial push
**Step 1: Create repository on Gitea**
> Manual step: Create `project-afterlife` repo on git.consultoria-as.com
**Step 2: Rename branch to main**
Run: `git branch -m master main`
**Step 3: Add remote and push**
Run: `git remote add origin https://git.consultoria-as.com/consultoria-as/project-afterlife.git`
Run: `git push -u origin main`
---
## Summary
| Phase | Tasks | Description |
|-------|-------|-------------|
| 1 | 1-2 | Monorepo scaffold + shared types |
| 2 | 3-4 | Strapi CMS with content types |
| 3 | 5-7 | Next.js setup, i18n, API client |
| 4 | 8-13 | All frontend pages (landing, catalog, game, documentary, about, donate) |
| 5 | 14-15 | Docker, Nginx, CI/CD |
| 6 | 16-17 | SEO, metadata, deploy |
**Total: 17 tasks across 6 phases**
Each task is independently committable. Tasks within a phase should be done in order. Phases are sequential (each depends on the previous).