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

72 KiB
Raw Permalink Blame History

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

{
  "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

{
  "$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

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

{
  "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

{
  "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:

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:

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:

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:

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:

export * from "./game";
export * from "./documentary";
export * from "./chapter";
export * from "./api";

packages/shared/src/index.ts:

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

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:

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:

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

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:

{
  "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:

{
  "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:

{
  "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:

import { factories } from "@strapi/strapi";
export default factories.createCoreRouter("api::{name}.{name}");

apps/cms/src/api/{name}/controllers/{name}.ts:

import { factories } from "@strapi/strapi";
export default factories.createCoreController("api::{name}.{name}");

apps/cms/src/api/{name}/services/{name}.ts:

import { factories } from "@strapi/strapi";
export default factories.createCoreService("api::{name}.{name}");

Step 5: Commit

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:

"@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

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:

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:

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:

{
  "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:

{
  "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:

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.tsxapps/web/src/app/[locale]/layout.tsx Move apps/web/src/app/page.tsxapps/web/src/app/[locale]/page.tsx

Update apps/web/src/app/[locale]/layout.tsx:

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:

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

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:

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:

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

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

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:

"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:

"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:

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:

<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

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:

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:

"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:

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:

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:

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

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:

"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:

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:

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

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:

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:

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:

"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:

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

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:

"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:

"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:

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:

"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:

"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:

"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:

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

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:

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:

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

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:

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:

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:

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:

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

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:

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

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:

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:

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

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).