Files
dark-lord/app/forum/post/[id]/page.tsx
drjones 78a071ba02 Harden onion boot flow and deepen site surfaces
Add persistent onion key backup and restore, improve startup resilience, and flesh out the major site verticals with richer navigation, search coverage, and operator documentation.

Made-with: Cursor
2026-04-07 21:35:52 -07:00

175 lines
6.5 KiB
TypeScript

"use client";
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useAccount } from "@/contexts/AccountContext";
import { formatRingLabel, getRing } from "@/lib/forumRings";
import {
addReply,
displayScore,
getThread,
isReadOnlyThread,
type ForumThread,
} from "@/lib/forumState";
import { ThreadVoteColumn } from "@/components/forum/ThreadVoteColumn";
export default function VoidAggregatePostPage() {
const params = useParams();
const id = typeof params.id === "string" ? params.id : "";
const { user, hydrated: accountReady } = useAccount();
const prefilledAuthor = useRef(false);
const [thread, setThread] = useState<ForumThread | null>(null);
const [body, setBody] = useState("");
const [author, setAuthor] = useState("");
const [replyErr, setReplyErr] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const refresh = useCallback(() => {
setThread(getThread(id) ?? null);
}, [id]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!accountReady || !user || prefilledAuthor.current) return;
setAuthor(user.displayName);
prefilledAuthor.current = true;
}, [accountReady, user]);
const onReply = (e: FormEvent) => {
e.preventDefault();
setReplyErr(null);
if (!id) return;
if (addReply(id, author, body)) {
setBody("");
refresh();
} else {
setReplyErr("Staff-locked — open a new thread to discuss.");
}
};
if (!id) {
return <p className="font-mono text-[#8a7d6b]">Invalid post.</p>;
}
if (!thread) {
return (
<div className="font-mono">
<p className="text-[#ffcb8a]">Post not found.</p>
<Link href="/forum" className="mt-4 inline-block text-sm text-[#ff9a3c] underline">
front page
</Link>
</div>
);
}
const t = thread;
const ring = getRing(t.topicSlug);
const locked = isReadOnlyThread(t.id);
const copyPermalink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<div className="mx-auto max-w-3xl">
<nav className="flex flex-wrap items-center justify-between gap-2 font-mono text-[11px] text-[#8a7d6b]">
<div className="flex flex-wrap items-center gap-2">
<Link href="/forum" className="hover:text-[#ffcb8a]">
Void Aggregate
</Link>
<span className="text-[#3d3228]">/</span>
<Link href={`/forum/ring/${t.topicSlug}`} className="text-[#ff9a3c] hover:underline">
{formatRingLabel(t.topicSlug)}
</Link>
</div>
<button
type="button"
onClick={() => void copyPermalink()}
className="rounded border border-[#3d3228] px-2 py-1 text-[10px] uppercase tracking-wider text-[#b8a995] hover:border-[#ff9a3c]/50 hover:text-[#ffcb8a]"
>
{copied ? "Copied" : "Copy link"}
</button>
</nav>
{locked ? (
<div className="mt-4 rounded border border-[#5c4030] bg-[#1a1510]/90 px-4 py-3 font-mono text-xs text-[#c4b5a0]">
<strong className="text-[#ffcb8a]">Staff-controlled thread.</strong> Replies are closed on hub-seeded posts open a new topic in{" "}
<Link href={`/forum/submit?ring=${encodeURIComponent(t.topicSlug)}`} className="text-[#ff9a3c] underline">
r/{ring?.shortName ?? t.topicSlug}
</Link>{" "}
to continue the conversation.
</div>
) : null}
<article className="mt-6 flex gap-4 rounded-lg border border-[#3d3228] bg-[#120e0a]/95 p-6">
<ThreadVoteColumn thread={t} onVoteChange={refresh} />
<div className="min-w-0 flex-1">
{ring ? (
<p className="font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/70">
r/{ring.shortName} · {displayScore(t)} points
</p>
) : null}
<h1 className="mt-2 font-mono text-2xl font-black leading-tight text-[#ffcb8a] md:text-3xl">{t.title}</h1>
<p className="mt-3 font-mono text-xs text-[#8a7d6b]">
Posted by <span className="text-[#b8a995]">{t.author || "Anonymous"}</span> · {new Date(t.ts).toLocaleString()}
</p>
<div className="mt-6 whitespace-pre-wrap border-t border-[#2a2218] pt-6 font-mono text-sm leading-relaxed text-[#e7d9c8]">
{t.body}
</div>
</div>
</article>
<section className="mt-10">
<h2 className="font-mono text-sm font-bold uppercase tracking-[0.2em] text-[#ff9a3c]/80">
Comments · {t.replies.length}
</h2>
<div className="mt-4 space-y-3">
{t.replies.map((r) => (
<div key={r.id} className="rounded border border-[#3d3228] bg-[#0d0a07]/90 p-4 pl-6 font-mono text-sm">
<p className="text-[10px] uppercase tracking-wider text-[#8a7d6b]">
{r.author} · {new Date(r.ts).toLocaleString()}
</p>
<p className="mt-2 whitespace-pre-wrap leading-relaxed text-[#d8cfc3]">{r.body}</p>
</div>
))}
</div>
</section>
{locked ? null : (
<form onSubmit={onReply} className="mt-10 rounded-lg border border-dashed border-[#3d3228] bg-[#0d0a07]/50 p-6">
<h3 className="font-mono text-xs font-bold uppercase tracking-widest text-[#ffcb8a]">Add comment</h3>
{replyErr ? <p className="mt-2 font-mono text-xs text-[#ff6b35]">{replyErr}</p> : null}
<input
className="mt-4 w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] focus:border-[#ff9a3c] focus:outline-none"
placeholder="Name"
value={author}
onChange={(e) => setAuthor(e.target.value)}
/>
<textarea
className="mt-3 min-h-[120px] w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] focus:border-[#ff9a3c] focus:outline-none"
placeholder="Comment text…"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<button
type="submit"
className="mt-4 rounded border-2 border-[#ff9a3c] bg-[#ff9a3c] px-6 py-2 font-mono text-xs font-black uppercase text-black hover:bg-[#ffcb8a]"
>
Comment
</button>
</form>
)}
</div>
);
}