Files
minecraft-account-manager/apps/web/src/components/suggestion-reader.tsx
T
dmg 2402e9e42d
CI / validate (push) Successful in 7m26s
Release / release (push) Successful in 9m43s
feat(admin): browse Discord suggestions in the portal
2026-09-10 08:21:08 -04:00

68 lines
4.8 KiB
TypeScript

"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>;
}