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
175 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|