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
This commit is contained in:
174
app/forum/post/[id]/page.tsx
Normal file
174
app/forum/post/[id]/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user