Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
dcf2417
docs(spec): forum design — old-school boards, modernized
Jul 15, 2026
5615a48
docs(plan): forum implementation plan — 27 tasks across phases A–D
Jul 15, 2026
ba9b26d
feat(auth): users, sessions, login_tokens tables
Jul 15, 2026
3c9f40f
feat(auth): token crypto + username/email validation
Jul 15, 2026
875cc2e
feat(auth): magic-link email sender (Resend via fetch, console fallback)
Jul 15, 2026
727ad14
fix(auth): never include the Resend response body in errors (may echo…
Jul 15, 2026
1cd302f
feat(auth): magic-link request flows (signup/login/email-change)
Jul 15, 2026
2129ef3
feat(auth): token verification, user creation, sliding sessions
Jul 15, 2026
329325f
fix(auth): survive the signup username race (unique-violation -> user…
Jul 15, 2026
5bc0184
feat(auth): join/login/verify pages, session cookie, user strip
Jul 15, 2026
cfbcfda
fix(auth): send magic links after the response — no account-enumerati…
Jul 15, 2026
7814a4e
feat(auth): settings (signature, email change) + make-admin script
Jul 15, 2026
bfd5788
feat(forum): categories/boards/threads/posts schema + seeded boards
Jul 15, 2026
2841b8f
feat(forum): BBCode parser (safe AST, literal fallbacks) + constants
Jul 15, 2026
859eebc
feat(forum): safe BBCode React renderer
Jul 15, 2026
674514d
feat(forum): thread/post/edit mutations with transactional counters
Jul 15, 2026
9d9e43d
feat(forum): read queries (board index, threads, posts, profiles)
Jul 15, 2026
3c254c8
fix(forum): clamp user-supplied page numbers in the read layer
Jul 15, 2026
5d5f056
feat(forum): board index page, forum URLs, nav link
Jul 15, 2026
02b3ed2
feat(forum): board page, pager, new-thread composer + actions
Jul 15, 2026
42b6eff
feat(forum): thread page with PostCard, canonical URLs, JSON-LD
Jul 15, 2026
df48ae0
fix(forum): thread JSON-LD only on page 1 (page 2+ misattributed the OP)
Jul 15, 2026
216228e
feat(forum): reply composer and post editing
Jul 15, 2026
0ee82f0
feat(forum): member profile pages
Jul 15, 2026
6dfc464
feat(forum): like/honk reactions
Jul 15, 2026
0443a1a
fix(forum): validate reaction redirect target + idempotent reaction i…
Jul 15, 2026
d6d98ce
feat(forum): per-member unread markers with jump-to-first-unread
Jul 15, 2026
1210468
feat(forum): identicon avatars + signatures under posts
Jul 15, 2026
6646502
fix(forum): avatar aria-label keeps display case (a11y)
Jul 15, 2026
51bed00
feat(forum): quoting with JS-off fallback
Jul 15, 2026
84832e5
test(forum): pin quoteBBCode sibling/unclosed/triple-nest behavior
Jul 15, 2026
f24efa1
feat(forum): who's online
Jul 15, 2026
1b497a3
feat(forum): post reports
Jul 15, 2026
9abe4c9
feat(forum): admin moderation (delete/lock/pin/ban) + admin queue
Jul 15, 2026
382dbdf
fix(forum): harden safeBack against backslash-normalized open redirects
Jul 15, 2026
85d3c63
feat(forum): posting throttles, signup honeypot, magic-link rate limits
Jul 15, 2026
24412f9
fix(forum): move safeBack out of the use-server actions file (all rou…
Jul 15, 2026
43fec5d
fix(auth): login rate-limit must not leak account existence (enumerat…
Jul 15, 2026
1953f98
feat(forum): verify counters, sitemap entries, deploy runbook
Jul 15, 2026
e5342da
docs(readme): forum roadmap marker matches the table's plain 'done'
Jul 15, 2026
19efc43
fix(forum): block banned users from signature/email writes + guard em…
Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary><b>Getting started</b></summary>
Expand Down Expand Up @@ -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/).
Expand Down
1 change: 1 addition & 0 deletions app/_components/mobile-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 1 addition & 0 deletions app/_components/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
28 changes: 28 additions & 0 deletions app/forum/[board]/new/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container className="py-10">
<h1 className={experience === "fancy" ? "font-display text-3xl tracking-tight" : "text-2xl font-bold"}>
New thread in {info.title}
</h1>
<div className="mt-6 max-w-2xl">
<Composer action={newThreadAction} hidden={{ boardId: info.id, boardSlug: info.slug }} withTitle submitLabel="Post thread" />
</div>
</Container>
);
}
76 changes: 76 additions & 0 deletions app/forum/[board]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 = (
<Link href={`/forum/${info.slug}/new`} className="border border-line px-3 py-1 text-sm hover:border-line-soft">
Post New Thread
</Link>
);
const pager = <Pager current={page} total={totalPages} href={(p) => boardPath(info.slug, p)} />;

if (experience === "minimal") {
return (
<Container className="py-8">
<Doc>
<Breadcrumb trail={[{ href: "/", label: "Goose Index" }, { href: "/forum", label: "Forum" }, { label: info.title }]} />
<h1>{info.title}</h1>
<p>{info.description}</p>
<UserStrip />
<p>{newThread}</p>
<ThreadTable rows={rows} experience={experience} />
{pager}
</Doc>
</Container>
);
}
return (
<Container className="py-10">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs text-muted"><Link href="/forum" className="hover:underline">Forum</Link> ›</p>
<h1 className={experience === "fancy" ? "font-display text-3xl tracking-tight" : "text-2xl font-bold"}>{info.title}</h1>
<p className="text-sm text-muted">{info.description}</p>
</div>
<div className="flex items-center gap-3">
<UserStrip />
{newThread}
</div>
</div>
<div className="mt-6">
<ThreadTable rows={rows} experience={experience} />
</div>
<div className="mt-4">{pager}</div>
</Container>
);
}
29 changes: 29 additions & 0 deletions app/forum/_components/board-index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BoardIndex categories={categories} experience="minimal" />);
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(<BoardIndex categories={categories} experience="fancy" />);
expect(html).toContain("surface-card");
expect(html).toContain("No posts yet");
});
});
66 changes: 66 additions & 0 deletions app/forum/_components/board-index.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<section key={c.id}>
<h2>{c.title}</h2>
<ul>
{c.boards.map((b) => (
<li key={b.id}>
<Link href={`/forum/${b.slug}`} className="underline">{b.title}</Link> — {b.description}{" "}
({b.threadCount} threads · {b.postCount} posts)
{b.lastPost && (
<> · last: <Link className="underline" href={threadPath(b.lastPost.threadId, b.lastPost.threadSlug)}>
{b.lastPost.threadTitle}</Link> by {b.lastPost.author} at {b.lastPost.at}</>
)}
</li>
))}
</ul>
</section>
))}
</>
);
}
const fancy = experience === "fancy";
return (
<div className="flex flex-col gap-6">
{categories.map((c) => (
<section key={c.id}>
<h2 className={fancy ? "font-display text-xl text-ink" : "text-sm font-bold uppercase tracking-wide text-muted"}>
{c.title}
</h2>
<div className={clsx("mt-2 divide-y divide-line border border-line", fancy && "surface-card rounded-lg")}>
{c.boards.map((b) => (
<div key={b.id} className="flex flex-wrap items-baseline gap-x-4 gap-y-1 px-4 py-3">
<div className="min-w-0 flex-1">
<Link href={`/forum/${b.slug}`} className="font-medium text-ink hover:underline">{b.title}</Link>
<p className="text-sm text-muted">{b.description}</p>
</div>
<div className="font-mono text-xs text-muted">{b.threadCount} threads · {b.postCount} posts</div>
<div className="w-full text-xs text-muted sm:w-72">
{b.lastPost ? (
<>
<Link href={threadPath(b.lastPost.threadId, b.lastPost.threadSlug)} className="block truncate hover:underline">
{b.lastPost.threadTitle}
</Link>
<span>by {b.lastPost.author} · {b.lastPost.at}</span>
</>
) : (
<span>No posts yet</span>
)}
</div>
</div>
))}
</div>
</section>
))}
</div>
);
}
39 changes: 39 additions & 0 deletions app/forum/_components/composer.tsx
Original file line number Diff line number Diff line change
@@ -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<ForumFormState>;

const field = "border border-line bg-transparent px-2 py-1";

export function Composer({ action, hidden, withTitle = false, initialBody = "", submitLabel }: {
action: ForumAction;
hidden: Record<string, string | number>;
withTitle?: boolean;
initialBody?: string;
submitLabel: string;
}) {
const [state, formAction] = useActionState(action, {} as ForumFormState);
return (
<form id="composer" action={formAction} className="flex flex-col gap-3 text-sm">
{Object.entries(hidden).map(([k, v]) => (
<input key={k} type="hidden" name={k} value={v} />
))}
{withTitle && (
<label className="flex flex-col gap-1">Title
<input name="title" required minLength={3} maxLength={120} defaultValue={state.draftTitle} className={field} />
</label>
)}
<label className="flex flex-col gap-1">
<span>Message — [b]bold[/b] [i]italic[/i] [url=…]link[/url] [quote=name]…[/quote] [code]…[/code]</span>
<textarea id="composer-body" name="body" required rows={8} maxLength={20000}
defaultValue={state.draftBody ?? initialBody} className={`${field} font-mono`} />
</label>
{state.error && <p role="alert" className="text-red-600">{state.error}</p>}
<button type="submit" className="self-start border border-line px-3 py-1 hover:border-line-soft">{submitLabel}</button>
</form>
);
}
42 changes: 42 additions & 0 deletions app/forum/_components/forms.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { JoinForm, LoginForm, VerifyForm, SettingsForm, EmailChangeForm, type AuthAction } from "./forms";

const stub: AuthAction = async () => ({});

describe("auth forms", () => {
it("JoinForm renders username + email inputs with old-school constraints", () => {
const html = renderToStaticMarkup(<JoinForm action={stub} stamp="1.abc" />);
expect(html).toContain('name="username"');
expect(html).toContain('name="email"');
expect(html).toContain('pattern="[A-Za-z0-9_-]+"');
});
it("JoinForm carries a hidden stamp and a visually hidden honeypot field", () => {
const html = renderToStaticMarkup(<JoinForm action={stub} stamp="1.abc" />);
expect(html).toContain('name="stamp"');
expect(html).toContain('value="1.abc"');
expect(html).toContain('name="website"');
expect(html).toContain('tabindex="-1"');
});
it("LoginForm renders only an email input", () => {
const html = renderToStaticMarkup(<LoginForm action={stub} />);
expect(html).toContain('name="email"');
expect(html).not.toContain('name="username"');
});
it("VerifyForm carries the token in a hidden input", () => {
const html = renderToStaticMarkup(<VerifyForm action={stub} token="T123" />);
expect(html).toContain('type="hidden"');
expect(html).toContain('value="T123"');
expect(html).toContain("Complete sign-in");
});
});

it("SettingsForm shows the current signature and its limit", () => {
const html = renderToStaticMarkup(<SettingsForm action={stub} initialSignature="honk" />);
expect(html).toContain("honk");
expect(html).toContain('maxLength="200"'); // React static markup lowercases attributes
});
it("EmailChangeForm never displays an email address", () => {
const html = renderToStaticMarkup(<EmailChangeForm action={stub} />);
expect(html).not.toContain("@"); // page copy avoids emails entirely
});
Loading