# 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 { data: T; meta: { pagination?: { page: number; pageSize: number; pageCount: number; total: number; }; }; } export interface StrapiListResponse { 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 ( {children} ); } ``` 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; locale?: string; } export async function strapiGet({ path, params, locale }: FetchOptions): Promise { 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> { return strapiGet({ path: "/games", locale, params: { "populate[coverImage]": "*", "populate[documentary]": "*", "sort": "createdAt:desc", }, }); } export async function getGameBySlug(slug: string, locale: string): Promise> { 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 { 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> { 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 (
{t("language")}: |
); } ``` **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 ( ); } ``` **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 (

{t("rights")}

); } ``` **Step 5: Update layout to include Navbar and Footer** Update `apps/web/src/app/[locale]/layout.tsx` body to include: ```tsx
{children}