feat(admin): browse Discord suggestions in the portal
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user