feat(admin): browse Discord suggestions in the portal
CI / validate (push) Successful in 7m26s
Release / release (push) Successful in 9m43s

This commit is contained in:
dmg
2026-09-10 08:21:08 -04:00
parent 0af4884f7e
commit 2402e9e42d
15 changed files with 441 additions and 8 deletions
@@ -39,6 +39,7 @@ export default async function AdminConsoleLayout({ children }: { children: React
<Link className="hover:text-accent" href="/admin/users">Users</Link>
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
<Link className="hover:text-accent" href="/admin/rcon">RCON</Link>
<Link className="hover:text-accent" href="/admin/suggestions">Suggestions</Link>
<Link className="hover:text-accent" href="/admin/events">Events</Link>
</nav>
<AdminSignOutButton />
@@ -0,0 +1,16 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { SuggestionReader } from "@/components/suggestion-reader";
import { requireAdminSession } from "@/lib/auth/require-admin";
export const dynamic = "force-dynamic";
export default async function SuggestionPage({ params }: { params: Promise<{ id: string }> }) {
await requireAdminSession();
const { id } = await params;
if (!/^[1-9]\d{16,19}$/.test(id)) notFound();
return <main className="mx-auto max-w-6xl px-6 py-14">
<Link className="mb-8 inline-block font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4 hover:text-accent" href="/admin/suggestions"> All suggestions</Link>
<SuggestionReader id={id} key={id} />
</main>;
}
@@ -0,0 +1,14 @@
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it, vi } from "vitest";
vi.mock("next-auth", () => ({ getServerSession: async () => ({ user: { roles: ["ops"] } }) }));
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
vi.mock("@minecraft-account-manager/database", () => ({ recordEvent: vi.fn() }));
vi.mock("@/lib/database", () => ({ db: {} }));
vi.mock("@/components/admin-sign-out-button", () => ({ AdminSignOutButton: () => <button>Sign out</button> }));
import AdminConsoleLayout from "../layout";
it("adds suggestions to administrator navigation", async () => {
const markup = renderToStaticMarkup(await AdminConsoleLayout({ children: <p>Console</p> }));
expect(markup).toContain('href="/admin/suggestions"');
expect(markup).toContain("Suggestions</a>");
});
@@ -0,0 +1,26 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ requireAdmin: vi.fn() }));
vi.mock("@/lib/auth/require-admin", () => ({ requireAdminSession: mocks.requireAdmin }));
vi.mock("@/components/suggestions-browser", () => ({ SuggestionsBrowser: () => <div>Suggestions browser</div> }));
vi.mock("@/components/suggestion-reader", () => ({ SuggestionReader: ({ id }: { id: string }) => <div>Reader {id}</div> }));
import SuggestionsPage from "./page";
import SuggestionPage from "./[id]/page";
const params = Promise.resolve({ id: "100000000000000009" });
beforeEach(() => { mocks.requireAdmin.mockReset(); });
it("independently requires the admin guard before rendering either page", async () => {
mocks.requireAdmin.mockRejectedValue(new Error("Admin sign-in required"));
await expect(SuggestionsPage()).rejects.toThrow("Admin sign-in required");
await expect(SuggestionPage({ params })).rejects.toThrow("Admin sign-in required");
});
it("renders the browser and detail in the existing portal layout", async () => {
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
expect(renderToStaticMarkup(await SuggestionsPage())).toContain("Suggestions browser");
const detail = renderToStaticMarkup(await SuggestionPage({ params }));
expect(detail).toContain("Reader");
expect(detail).toContain('href="/admin/suggestions"');
});
it("rejects malformed IDs rather than constructing arbitrary API URLs", async () => {
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
await expect(SuggestionPage({ params: Promise.resolve({ id: "../secret" }) })).rejects.toThrow("NEXT_HTTP_ERROR_FALLBACK;404");
});
@@ -0,0 +1,19 @@
import { SuggestionsBrowser } from "@/components/suggestions-browser";
import { requireAdminSession } from "@/lib/auth/require-admin";
export const dynamic = "force-dynamic";
export default async function SuggestionsPage() {
await requireAdminSession();
return <main className="mx-auto max-w-6xl px-6 py-14">
<header className="relative overflow-hidden border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Community / Idea desk</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Suggestions</h1>
<div className="mt-5 flex flex-wrap items-end justify-between gap-5">
<p className="max-w-2xl text-sm leading-6 text-muted">Ideas from the Discord forum, brought into the operations desk. Read proposals and discussion here; keep the conversation in Discord.</p>
<span className="border border-ink bg-signal px-3 py-2 font-mono text-[10px] font-bold uppercase tracking-widest">Admin-only / Read-only</span>
</div>
</header>
<SuggestionsBrowser />
</main>;
}
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { SuggestionReader } from "./suggestion-reader";
const id = "100000000000000009";
const original = { id, author: { id: "100000000000000003", name: "Builder" }, content: "<script>unsafe()</script> Please build stations.", createdAt: "2026-01-01T00:00:00Z", editedAt: null, reactions: [{ emoji: "👍", count: 4 }], discordUrl: `https://discord.com/channels/100000000000000001/${id}/${id}` };
const detail = { id, title: "More railway stations", authorId: original.author.id, createdAt: original.createdAt, archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: `https://discord.com/channels/100000000000000001/${id}`, originalPost: original };
it("paginates discussion without duplicating the original post", async () => {
const fetcher = vi.fn().mockImplementation(async (url: string) => {
if (!url.includes("/messages")) return Response.json(detail);
const older = url.includes("cursor=");
return Response.json({ items: [original, { ...original, id: "100000000000000020", content: older ? "Earlier reply" : "Latest reply", editedAt: "2026-01-01T01:00:00Z" }], nextCursor: older ? null : "100000000000000020" });
});
vi.stubGlobal("fetch", fetcher);
render(<SuggestionReader id={id} />);
await screen.findByText("Latest reply");
expect(screen.getAllByText(original.content)).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
await screen.findByText("Earlier reply");
expect(fetcher.mock.lastCall?.[0]).toBe(`/api/suggestions/${id}/messages?limit=25&cursor=100000000000000020`);
expect(screen.getByText("2026-01-01T01:00:00Z")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
await screen.findByText("Latest reply");
});
it("distinguishes deleted starters and empty discussion", async () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : { ...detail, originalPost: null })));
render(<SuggestionReader id={id} />);
expect(await screen.findByText("The original post was deleted or is unavailable.")).toBeTruthy();
expect(await screen.findByText("No replies on this page.")).toBeTruthy();
});
it("handles detail access denial without loading the discussion", async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json({}, { status: 403 }));
vi.stubGlobal("fetch", fetcher);
render(<SuggestionReader id={id} />);
await screen.findByRole("link", { name: "Admin sign-in" });
expect(screen.queryByRole("heading", { name: "More railway stations" })).toBeNull();
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("makes empty message content explicit and retries discussion failures without writes", async () => {
let messageReads = 0;
const fetcher = vi.fn().mockImplementation(async (url: string) => {
if (!url.includes("/messages")) return Response.json({ ...detail, originalPost: { ...original, content: "" } });
messageReads += 1;
return messageReads === 1 ? new Response(null, { status: 503 }) : Response.json({ items: [], nextCursor: null });
});
vi.stubGlobal("fetch", fetcher);
render(<SuggestionReader id={id} />);
await screen.findByText("No text was returned. View this message in Discord.");
await screen.findByRole("alert");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
await screen.findByText("No replies on this page.");
expect(fetcher.mock.calls.every(([, options]) => options.method === undefined || options.method === "GET")).toBe(true);
});
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it("reads suggestion details and safely renders starter text, tags, reactions and Discord links", async () => {
const fetcher = vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : detail));
vi.stubGlobal("fetch", fetcher);
render(<SuggestionReader id={id} />);
expect(screen.getByRole("status").textContent).toContain("Loading suggestion");
await screen.findByRole("heading", { name: "More railway stations", level: 1 });
expect(screen.getByText(original.content)).toBeTruthy();
expect(document.querySelector("script")).toBeNull();
expect(screen.getByText("World")).toBeTruthy();
expect(screen.getByText("👍 4")).toBeTruthy();
expect(screen.getByRole("link", { name: "Open in Discord" }).getAttribute("href")).toBe(detail.discordUrl);
expect(fetcher.mock.calls[0]?.[0]).toBe(`/api/suggestions/${id}`);
expect(screen.queryByRole("textbox")).toBeNull();
});
@@ -0,0 +1,67 @@
"use client";
import type { MessagePage, SuggestionDetail, SuggestionMessage } from "@/lib/discord/suggestion-types";
import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared";
export function SuggestionReader({ id }: { id: string }) {
const { data, error, retry } = useSuggestionRead<SuggestionDetail>(`/api/suggestions/${id}`);
if (error) return <SuggestionFailure error={error} retry={retry} />;
if (!data) return <SuggestionLoading>Loading suggestion</SuggestionLoading>;
return <>
<header className="border-b border-line pb-8">
<p className="mb-5 font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Community proposal / Read-only</p>
<SuggestionBadges suggestion={data} />
<h1 className="mt-5 break-words font-display text-4xl font-black sm:text-6xl">{data.title}</h1>
<div className="mt-6 flex flex-wrap items-center justify-between gap-5">
<p className="break-all font-mono text-[10px] leading-5 text-muted">Thread / {data.id}<br /><time dateTime={data.createdAt}>{data.createdAt}</time></p>
<a className={`${suggestionControl} bg-ink text-panel`} href={data.discordUrl} rel="noopener noreferrer" target="_blank">Open in Discord</a>
</div>
</header>
<section aria-labelledby="original-post-heading" className="mt-10 border border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<div className="border-b border-line px-6 py-4"><h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.2em]" id="original-post-heading">Original suggestion</h2></div>
{data.originalPost ? <MessageCard message={data.originalPost} /> : <p className="p-6 text-sm text-muted">The original post was deleted or is unavailable.</p>}
</section>
<Discussion id={id} />
<p className="mt-6 text-xs leading-5 text-muted">Discord remains the source of truth. Open Discord for attachments, formatting, replies, and reactions. Message text may be unavailable if the bot lacks Message Content access.</p>
</>;
}
function Discussion({ id }: { id: string }) {
const paging = useSuggestionPages();
const query = new URLSearchParams({ limit: "25" });
if (paging.cursor) query.set("cursor", paging.cursor);
const url = `/api/suggestions/${id}/messages?${query}`;
return <section aria-labelledby="discussion-heading" className="mt-12">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<h2 className="font-display text-3xl font-black uppercase" id="discussion-heading">Discussion</h2>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted">Newest messages first / Read-only</p>
</header>
<DiscussionPage id={id} key={url} paging={paging} url={url} />
</section>;
}
function DiscussionPage({ id, url, paging }: { id: string; url: string; paging: SuggestionPaging }) {
const { data, error, retry } = useSuggestionRead<MessagePage>(url);
if (error) return <SuggestionFailure error={error} retry={retry} />;
if (!data) return <SuggestionLoading>Loading discussion</SuggestionLoading>;
const replies = data.items.filter((message) => message.id !== id);
return <>
<div className="divide-y divide-line border border-line bg-panel">
{replies.map((message) => <MessageCard key={message.id} message={message} />)}
{!replies.length && <p className="p-8 text-sm text-muted">No replies on this page.</p>}
</div>
<SuggestionPagination nextCursor={data.nextCursor} {...paging} />
</>;
}
function MessageCard({ message }: { message: SuggestionMessage }) {
return <article className="min-w-0 p-6 sm:p-8">
<header className="flex flex-wrap items-baseline justify-between gap-3">
<h3 className="break-words font-display text-lg font-black">{message.author.name}</h3>
<a aria-label={`Open message by ${message.author.name} in Discord`} className="font-mono text-[10px] text-muted underline underline-offset-4 hover:text-accent" href={message.discordUrl} rel="noopener noreferrer" target="_blank"><time dateTime={message.createdAt}>{message.createdAt}</time></a>
</header>
<p className="mt-5 whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">{message.content || "No text was returned. View this message in Discord."}</p>
{message.editedAt && <p className="mt-3 font-mono text-[10px] text-muted">Edited <time dateTime={message.editedAt}>{message.editedAt}</time></p>}
{!!message.reactions.length && <ul aria-label="Reaction counts" className="mt-5 flex flex-wrap gap-2">{message.reactions.map((reaction, index) => <li className="border border-line bg-canvas px-3 py-1 font-mono text-xs" key={`${reaction.emoji}-${index}`}>{reaction.emoji} {reaction.count}</li>)}</ul>}
</article>;
}
@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import type { Suggestion } from "@/lib/discord/suggestion-types";
export const suggestionControl = "border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider transition-colors hover:bg-ink hover:text-panel disabled:cursor-not-allowed disabled:opacity-40";
// Consumers remount on URL changes so previous-page data is never shown as new data.
type ReadFailure = { status: number; message: string };
class ReadError extends Error { constructor(public status: number) { super("Suggestions request failed"); } }
function readFailure(error: unknown): ReadFailure {
const status = error instanceof ReadError ? error.status : 0;
const messages: Record<number, string> = {
401: "Your admin session has expired. Sign in to continue.",
403: "Your account does not have the required administrator role.",
404: "This suggestion is no longer available in the configured forum.",
503: "Discord suggestions are temporarily unavailable or not configured. Try again shortly.",
};
return { status, message: messages[status] ?? "Suggestions could not be loaded. Try again shortly." };
}
export function useSuggestionRead<T>(url: string) {
const [result, setResult] = useState<{ data: T | null; error: ReadFailure | null }>({ data: null, error: null });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
fetch(url, { credentials: "same-origin", cache: "no-store", signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new ReadError(response.status);
return await response.json() as T;
})
.then((data) => { if (!controller.signal.aborted) setResult({ data, error: null }); })
.catch((error: unknown) => { if (!controller.signal.aborted) setResult({ data: null, error: readFailure(error) }); });
return () => controller.abort();
}, [url, attempt]);
return { ...result, retry: () => { setResult({ data: null, error: null }); setAttempt((value) => value + 1); } };
}
export function SuggestionBadges({ suggestion }: { suggestion: Suggestion }) {
return <div className="flex flex-wrap gap-2 font-mono text-[10px] font-bold uppercase tracking-wider">
<span className={`border border-ink px-2 py-1 ${suggestion.archived ? "bg-canvas text-muted" : "bg-signal text-ink"}`}>{suggestion.archived ? "Archived" : "Active"}</span>
{suggestion.locked && <span className="border border-line px-2 py-1 text-muted">Locked</span>}
{suggestion.tags.map((tag) => <span className="border border-line px-2 py-1 text-muted" key={tag.id}>{tag.name}</span>)}
</div>;
}
export function SuggestionFailure({ error, retry }: { error: ReadFailure; retry: () => void }) {
const authError = error.status === 401 || error.status === 403;
return <div className="border-l-4 border-accent bg-panel p-6" role="alert">
<h2 className="font-display text-2xl font-black uppercase">{authError ? "Admin access required" : "Unable to load suggestions"}</h2>
<p className="mt-3 text-sm leading-6 text-muted">{error.message}</p>
<div className="mt-5">{authError ? <Link className="font-mono text-xs font-bold underline underline-offset-4" href="/admin/login">Admin sign-in</Link> : <button className={suggestionControl} onClick={retry} type="button">Try again</button>}</div>
</div>;
}
export function useSuggestionPages() {
const [state, setState] = useState<{ cursors: (string | undefined)[]; hasPaged: boolean }>({ cursors: [undefined], hasPaged: false });
return {
cursor: state.cursors.at(-1), page: state.cursors.length, focusOnMount: state.hasPaged,
onNext: (cursor: string) => setState((current) => ({ cursors: [...current.cursors, cursor], hasPaged: true })),
onPrevious: () => setState((current) => ({ cursors: current.cursors.length > 1 ? current.cursors.slice(0, -1) : current.cursors, hasPaged: true })),
reset: () => setState({ cursors: [undefined], hasPaged: false }),
};
}
export type SuggestionPaging = { focusOnMount: boolean; onNext: (cursor: string) => void; onPrevious: () => void; page: number };
export function SuggestionPagination({ nextCursor, onNext, onPrevious, page, focusOnMount }: SuggestionPaging & { nextCursor: string | null }) {
const label = useRef<HTMLParagraphElement>(null);
useEffect(() => { if (focusOnMount) label.current?.focus(); }, [focusOnMount]);
return <nav aria-label="Pagination" className="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-line pt-6">
<button className={suggestionControl} disabled={page <= 1} onClick={onPrevious} type="button">Previous page</button>
<p aria-live="polite" className="font-mono text-[10px] uppercase tracking-wider text-muted" ref={label} tabIndex={-1}>Page {page}</p>
<button className={suggestionControl} disabled={!nextCursor} onClick={() => { if (nextCursor) onNext(nextCursor); }} type="button">Next page</button>
</nav>;
}
export function SuggestionLoading({ children }: { children: string }) {
return <div className="border border-line bg-panel p-8" role="status"><p className="font-mono text-xs uppercase tracking-widest">{children}</p><div aria-hidden="true" className="mt-6 h-1 w-20 bg-accent" /></div>;
}
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { SuggestionsBrowser } from "./suggestions-browser";
const suggestion = { id: "100000000000000009", title: "More railway stations", authorId: "100000000000000003", createdAt: "2026-01-01T00:00:00Z", archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: "https://discord.com/channels/100000000000000001/100000000000000009" };
it("switches archives, pages forward and back, and resets pagination on status change", async () => {
const fetcher = vi.fn().mockImplementation(async (url: string) => {
const archived = url.includes("status=archived");
const next = url.includes("cursor=");
return Response.json({ items: [{ ...suggestion, title: archived ? (next ? "Earlier archived idea" : "Archived idea") : "Active idea", archived }], nextCursor: next ? null : "2026-01-01T00:00:00Z" });
});
vi.stubGlobal("fetch", fetcher);
render(<SuggestionsBrowser />);
await screen.findByRole("link", { name: "Active idea" });
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
await screen.findByRole("link", { name: "Archived idea" });
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
await screen.findByRole("link", { name: "Earlier archived idea" });
expect(fetcher.mock.lastCall?.[0]).toContain("cursor=2026-01-01T00%3A00%3A00Z");
expect(document.activeElement?.textContent).toBe("Page 2");
expect((screen.getByRole("button", { name: "Next page" }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
await screen.findByRole("link", { name: "Archived idea" });
expect(document.activeElement?.textContent).toBe("Page 1");
fireEvent.click(screen.getByRole("button", { name: "Active" }));
await screen.findByRole("link", { name: "Active idea" });
expect(fetcher.mock.lastCall?.[0]).toBe("/api/suggestions?status=active&limit=25");
expect((screen.getByRole("button", { name: "Previous page" }) as HTMLButtonElement).disabled).toBe(true);
});
it("shows an explicit empty state", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ items: [], nextCursor: null })));
render(<SuggestionsBrowser />);
expect(await screen.findByText("No suggestions on this page.")).toBeTruthy();
});
it("retries failed reads and distinguishes expired admin access", async () => {
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ detail: "private upstream text" }, { status: 503 })).mockResolvedValueOnce(Response.json({ items: [], nextCursor: null }));
vi.stubGlobal("fetch", fetcher);
render(<SuggestionsBrowser />);
expect((await screen.findByRole("alert")).textContent).not.toContain("private upstream text");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
await screen.findByText("No suggestions on this page.");
fetcher.mockResolvedValueOnce(Response.json({}, { status: 401 }));
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
expect((await screen.findByRole("link", { name: "Admin sign-in" })).getAttribute("href")).toBe("/admin/login");
expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
});
it("ignores a late response from the previously selected status", async () => {
let resolveActive!: (response: Response) => void;
const fetcher = vi.fn().mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveActive = resolve; })).mockResolvedValueOnce(Response.json({ items: [{ ...suggestion, title: "Archived result", archived: true }], nextCursor: null }));
vi.stubGlobal("fetch", fetcher);
render(<SuggestionsBrowser />);
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
await screen.findByRole("link", { name: "Archived result" });
resolveActive(Response.json({ items: [suggestion], nextCursor: null }));
await waitFor(() => expect(fetcher.mock.calls[0]?.[1].signal.aborted).toBe(true));
expect(screen.queryByRole("link", { name: "More railway stations" })).toBeNull();
});
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
it("loads and displays suggestions with tags and links through the admin API", async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json({ items: [suggestion], nextCursor: null }));
vi.stubGlobal("fetch", fetcher);
render(<SuggestionsBrowser />);
expect(screen.getByRole("status").textContent).toContain("Loading suggestions");
const link = await screen.findByRole("link", { name: "More railway stations" });
expect(link.getAttribute("href")).toBe(`/admin/suggestions/${suggestion.id}`);
expect(screen.getByText("World")).toBeTruthy();
expect(screen.getByRole("link", { name: "Open More railway stations in Discord" }).getAttribute("href")).toBe(suggestion.discordUrl);
expect(fetcher).toHaveBeenCalledWith("/api/suggestions?status=active&limit=25", expect.objectContaining({ credentials: "same-origin", cache: "no-store" }));
});
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import type { SuggestionPage } from "@/lib/discord/suggestion-types";
import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared";
export function SuggestionsBrowser() {
const [status, setStatus] = useState<"active" | "archived">("active");
const paging = useSuggestionPages();
const query = new URLSearchParams({ status, limit: "25" });
if (paging.cursor) query.set("cursor", paging.cursor);
const url = `/api/suggestions?${query}`;
return <section aria-label="Suggestions" className="mt-10">
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div aria-label="Suggestion status" className="flex gap-2">
{(["active", "archived"] as const).map((value) => <button aria-pressed={status === value} className={`${suggestionControl} ${status === value ? "bg-ink text-panel" : "bg-panel"}`} key={value} onClick={() => { setStatus(value); paging.reset(); }} type="button">{value === "active" ? "Active" : "Archived"}</button>)}
</div>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted">{status === "active" ? "Newest ideas first" : "Most recently archived first"}</p>
</div>
<SuggestionList key={url} paging={paging} url={url} />
</section>;
}
function SuggestionList({ url, paging }: { url: string; paging: SuggestionPaging }) {
const { data, error, retry } = useSuggestionRead<SuggestionPage>(url);
if (error) return <SuggestionFailure error={error} retry={retry} />;
if (!data) return <SuggestionLoading>Loading suggestions</SuggestionLoading>;
return <><ol className="divide-y divide-line border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
{!data.items.length && <li className="p-10"><h2 className="font-display text-2xl font-black uppercase">No suggestions on this page.</h2><p className="mt-3 text-sm text-muted">Try the other status, or return to the previous page.</p></li>}
{data.items.map((suggestion, index) => <li className="grid gap-5 p-6 sm:grid-cols-[3rem_1fr_auto] sm:p-8" key={suggestion.id}>
<span aria-hidden="true" className="font-mono text-sm text-muted">{String(index + 1).padStart(2, "0")}</span>
<div className="min-w-0">
<SuggestionBadges suggestion={suggestion} />
<h2 className="mt-4 break-words font-display text-2xl font-black sm:text-3xl"><Link className="underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/suggestions/${suggestion.id}`}>{suggestion.title}</Link></h2>
<p className="mt-3 break-words font-mono text-[10px] leading-5 text-muted"><time dateTime={suggestion.createdAt}>{suggestion.createdAt}</time><br />Author / {suggestion.authorId}</p>
</div>
<div className="flex items-end justify-between gap-6 sm:flex-col sm:items-end">
<p className="font-mono text-[10px] uppercase text-muted">~{suggestion.messageCount} messages</p>
<a aria-label={`Open ${suggestion.title} in Discord`} className="font-mono text-[10px] font-bold uppercase underline underline-offset-4 hover:text-accent" href={suggestion.discordUrl} rel="noopener noreferrer" target="_blank">Discord </a>
</div>
</li>)}
</ol><SuggestionPagination nextCursor={data.nextCursor} {...paging} /></>;
}
@@ -26,6 +26,18 @@ it("reads archived forum pages using Discord's archive timestamp cursor", async
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
});
it("normalizes offset archive timestamps without losing cursor precision", async () => {
const raw = "2026-01-01T00:00:00.123456+00:00";
const cursor = "2026-01-01T00:00:00.123456Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
const first = await client.list({ status: "archived", limit: 1 });
expect(first.nextCursor).toBe(cursor);
expect(await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! })).toEqual({ items: [], nextCursor: null });
});
const message = { id: threadId, content: "Please add stations", timestamp: "2026-01-01T00:00:00.000Z", edited_timestamp: null, author: { id: "100000000000000003", username: "builder", global_name: "Builder" }, reactions: [{ emoji: { name: "👍" }, count: 4 }] };
it("returns the starter post and paginates discussion with authors and reactions", async () => {
const { client } = setup({
+8 -1
View File
@@ -30,6 +30,13 @@ function timestamp(value: string | number) {
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
}
function archiveCursor(value: string) {
// Discord timestamps may use +00:00 and microseconds. Date alone truncates
// that precision, potentially skipping posts at the archive page boundary.
const fraction = value.match(/\.(\d{1,6})(?:Z|[+-]\d{2}:\d{2})$/)?.[1];
return new Date(value).toISOString().replace(/\.\d{3}Z$/, fraction && /[1-9]/.test(fraction) ? `.${fraction}Z` : "Z");
}
export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) {
const { token, guildId, forumId } = options;
const fetcher = options.fetch ?? fetch;
@@ -144,7 +151,7 @@ export function createSuggestionsClient(options: { token: string; guildId: strin
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`);
return {
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)),
nextCursor: data.has_more && data.threads.length ? data.threads.at(-1)!.thread_metadata.archive_timestamp.replace(/\.000Z$/, "Z") : null,
nextCursor: data.has_more && data.threads.length ? archiveCursor(data.threads.at(-1)!.thread_metadata.archive_timestamp) : null,
};
}
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);