diff --git a/README.md b/README.md index a32ba51..8edd89c 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ job, so page loads never depend on the elgoose API being up. - **Postgres + Drizzle** — a cached copy of the live-performance record. - **Vitest** — the suite runs fully offline (fixtures + in-memory PGlite). No network, no database. - **Vercel Web Analytics** — cookieless page views, on every edition. +- **The Forum** — passwordless (magic-link) accounts via [Resend](https://resend.com), BBCode + posts, reactions, and moderation, layered on the same read-mostly Postgres.
Getting started @@ -173,6 +175,7 @@ Two things worth knowing up front: | **1** | Shows & discovery — setlists, search, On This Day, upcoming | done | | **2** | Songs & stats — per-song pages, song index, `/stats` cuts | done | | **3** | Jam & set-flow analytics — segue lines, jam density by night and venue, the shelf | Oracle ships the first cut; era-aware analysis still open | +| **F** | **The Forum** — XenForo-style boards, magic-link accounts, BBCode, reactions, moderation | done | | 4 | Fan tracking — shows I've seen, personal stats, song life-list | planned | Design specs per phase: [`docs/superpowers/specs/`](docs/superpowers/specs/). diff --git a/app/_components/mobile-nav.tsx b/app/_components/mobile-nav.tsx index e03faf8..b2e003f 100644 --- a/app/_components/mobile-nav.tsx +++ b/app/_components/mobile-nav.tsx @@ -9,6 +9,7 @@ const NAV = [ { href: "/shows", label: "Shows" }, { href: "/songs", label: "Songs" }, { href: "/stats", label: "Stats" }, + { href: "/forum", label: "Forum" }, { href: "/on-this-day", label: "On This Day" }, { href: "/venues", label: "Venues" }, { href: "/tours", label: "Tours" }, diff --git a/app/_components/site-header.tsx b/app/_components/site-header.tsx index 9e15910..7928035 100644 --- a/app/_components/site-header.tsx +++ b/app/_components/site-header.tsx @@ -12,6 +12,7 @@ const NAV = [ { href: "/shows", label: "Shows" }, { href: "/songs", label: "Songs" }, { href: "/stats", label: "Stats" }, + { href: "/forum", label: "Forum" }, { href: "/on-this-day", label: "On This Day" }, { href: "/venues", label: "Venues" }, { href: "/tours", label: "Tours" }, diff --git a/app/forum/[board]/new/page.tsx b/app/forum/[board]/new/page.tsx new file mode 100644 index 0000000..1d79f1a --- /dev/null +++ b/app/forum/[board]/new/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Container } from "@/app/_components/container"; +import { getExperience } from "@/lib/experience.server"; +import { requireUser } from "@/lib/auth/session.server"; +import { getBoard } from "@/lib/queries/forum"; +import { Composer } from "../../_components/composer"; +import { newThreadAction } from "../../actions"; + +export const metadata: Metadata = { title: "New thread", robots: { index: false } }; + +export default async function NewThreadPage({ params }: { params: Promise<{ board: string }> }) { + const { board } = await params; + const info = await getBoard(board); + if (!info) notFound(); + await requireUser(`/forum/${board}/new`); + const experience = await getExperience(); + return ( + +

+ New thread in {info.title} +

+
+
+
+ ); +} diff --git a/app/forum/[board]/page.tsx b/app/forum/[board]/page.tsx new file mode 100644 index 0000000..10050d9 --- /dev/null +++ b/app/forum/[board]/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Container } from "@/app/_components/container"; +import { Doc, Breadcrumb } from "@/app/_components/doc"; +import { getExperience } from "@/lib/experience.server"; +import { currentUser } from "@/lib/auth/session.server"; +import { getBoard, getThreadRows } from "@/lib/queries/forum"; +import { THREADS_PER_PAGE } from "@/lib/forum/constants"; +import { boardPath } from "@/lib/forum/urls"; +import { ThreadTable } from "../_components/thread-list"; +import { Pager } from "../_components/pager"; +import { UserStrip } from "../_components/user-strip"; + +type Params = Promise<{ board: string }>; +type SearchParams = Promise<{ page?: string }>; + +export async function generateMetadata({ params }: { params: Params }): Promise { + const info = await getBoard((await params).board); + return { title: info ? `${info.title} — Forum` : "Forum" }; +} + +export default async function BoardPage({ params, searchParams }: { params: Params; searchParams: SearchParams }) { + const [{ board }, sp] = await Promise.all([params, searchParams]); + const info = await getBoard(board); + if (!info) notFound(); + const page = Math.max(1, parseInt(sp.page ?? "1", 10) || 1); + const viewer = await currentUser(); + const [rows, experience] = await Promise.all([ + getThreadRows(info.id, page, viewer ? { userId: viewer.id, markAllReadAt: viewer.markAllReadAt } : null), + getExperience(), + ]); + const totalPages = Math.max(1, Math.ceil(info.threadCount / THREADS_PER_PAGE)); + + const newThread = ( + + Post New Thread + + ); + const pager = boardPath(info.slug, p)} />; + + if (experience === "minimal") { + return ( + + + +

{info.title}

+

{info.description}

+ +

{newThread}

+ + {pager} +
+
+ ); + } + return ( + +
+
+

Forum ›

+

{info.title}

+

{info.description}

+
+
+ + {newThread} +
+
+
+ +
+
{pager}
+
+ ); +} diff --git a/app/forum/_components/board-index.test.tsx b/app/forum/_components/board-index.test.tsx new file mode 100644 index 0000000..5ee8117 --- /dev/null +++ b/app/forum/_components/board-index.test.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { BoardIndex } from "./board-index"; +import type { BoardIndexCategory } from "@/lib/queries/forum"; + +const categories: BoardIndexCategory[] = [{ + id: 1, title: "The Music", boards: [{ + id: 1, slug: "tour-talk", title: "Tour Talk", description: "The road.", + threadCount: 4, postCount: 20, + lastPost: { threadId: 9, threadSlug: "msg-n2", threadTitle: "MSG N2", author: "Tim", at: "2026-07-15 01:00" }, + }, { + id: 2, slug: "off-topic", title: "Off Topic", description: "Etc.", threadCount: 0, postCount: 0, lastPost: null, + }], +}]; + +describe("BoardIndex", () => { + it("minimal renders plain lists with board links", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('href="/forum/tour-talk"'); + expect(html).toContain("4 threads"); + expect(html).toContain('href="/forum/threads/9-msg-n2"'); + expect(html).not.toContain("surface-card"); + }); + it("fancy renders card sections; empty boards say so", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("surface-card"); + expect(html).toContain("No posts yet"); + }); +}); diff --git a/app/forum/_components/board-index.tsx b/app/forum/_components/board-index.tsx new file mode 100644 index 0000000..ecc0bd2 --- /dev/null +++ b/app/forum/_components/board-index.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; +import type { BoardIndexCategory } from "@/lib/queries/forum"; +import type { Experience } from "@/lib/experience"; +import { threadPath } from "@/lib/forum/urls"; +import { clsx } from "@/app/_components/clsx"; + +export function BoardIndex({ categories, experience }: { categories: BoardIndexCategory[]; experience: Experience }) { + if (experience === "minimal") { + return ( + <> + {categories.map((c) => ( +
+

{c.title}

+
    + {c.boards.map((b) => ( +
  • + {b.title} — {b.description}{" "} + ({b.threadCount} threads · {b.postCount} posts) + {b.lastPost && ( + <> · last: + {b.lastPost.threadTitle} by {b.lastPost.author} at {b.lastPost.at} + )} +
  • + ))} +
+
+ ))} + + ); + } + const fancy = experience === "fancy"; + return ( +
+ {categories.map((c) => ( +
+

+ {c.title} +

+
+ {c.boards.map((b) => ( +
+
+ {b.title} +

{b.description}

+
+
{b.threadCount} threads · {b.postCount} posts
+
+ {b.lastPost ? ( + <> + + {b.lastPost.threadTitle} + + by {b.lastPost.author} · {b.lastPost.at} + + ) : ( + No posts yet + )} +
+
+ ))} +
+
+ ))} +
+ ); +} diff --git a/app/forum/_components/composer.tsx b/app/forum/_components/composer.tsx new file mode 100644 index 0000000..2402efb --- /dev/null +++ b/app/forum/_components/composer.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useActionState } from "react"; + +// Drafts ride along in the state so a validation error re-renders the user's +// text even with JavaScript disabled (spec: errors preserve the draft). +export type ForumFormState = { error?: string; draftTitle?: string; draftBody?: string }; +export type ForumAction = (prev: ForumFormState, fd: FormData) => Promise; + +const field = "border border-line bg-transparent px-2 py-1"; + +export function Composer({ action, hidden, withTitle = false, initialBody = "", submitLabel }: { + action: ForumAction; + hidden: Record; + withTitle?: boolean; + initialBody?: string; + submitLabel: string; +}) { + const [state, formAction] = useActionState(action, {} as ForumFormState); + return ( +
+ {Object.entries(hidden).map(([k, v]) => ( + + ))} + {withTitle && ( + + )} +