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:
drjones
2026-04-07 21:35:52 -07:00
parent 52432dccfa
commit 78a071ba02
162 changed files with 21692 additions and 39 deletions

View File

@@ -0,0 +1,48 @@
import Link from "next/link";
export default function ForumHandbookPage() {
return (
<article className="rounded-lg border border-[#3d3228] bg-[#120e0a]/95 p-6 md:p-10">
<header className="border-b border-[#2a2218] pb-6">
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-[#ff9a3c]/60">Void Aggregate · canon</p>
<h1 className="mt-2 font-mono text-2xl font-black text-[#ffcb8a] md:text-3xl">Operator handbook</h1>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#b8a995]">
The forum is not a single scroll rings, submit flow, moderation doctrine, and reputation math are separate surfaces so Tor onions can deep-link without losing context. This page is the table of contents for that depth.
</p>
</header>
<ol className="mt-8 list-decimal space-y-6 pl-5 font-mono text-sm text-[#b8a995]">
<li>
<strong className="text-[#ffcb8a]">Ring topology</strong> each <code className="text-[#ff9a3c]/80">/forum/ring/*</code> route carries its own about blurb, subscriber fiction, and active reader count. The hub thread list is a projection, not the source of truth.
</li>
<li>
<strong className="text-[#ffcb8a]">Submission contract</strong> {" "}
<Link href="/forum/submit" className="text-[#ff9a3c] underline hover:text-[#ffcb8a]">
/forum/submit
</Link>{" "}
enforces title/body/ring choice before anything hits local storage or preview indexes.
</li>
<li>
<strong className="text-[#ffcb8a]">Moderation ladder</strong> documented in{" "}
<Link href="/forum/moderation" className="text-[#ff9a3c] underline hover:text-[#ffcb8a]">
Moderation doctrine
</Link>
: freezes, ring bans, and network-wide exile differ by severity.
</li>
<li>
<strong className="text-[#ffcb8a]">Reputation surface</strong> {" "}
<Link href="/forum/reputation" className="text-[#ff9a3c] underline hover:text-[#ffcb8a]">
Reputation &amp; weight
</Link>{" "}
explains how votes interact with ring-specific priors (fiction).
</li>
</ol>
<p className="mt-10 font-mono text-[10px] text-[#6b5c4d]">
<Link href="/forum" className="text-[#ff9a3c] hover:underline">
Forum front
</Link>
</p>
</article>
);
}

5
app/forum/layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import ForumSiteChrome from "@/components/forum/ForumSiteChrome";
export default function ForumSectionLayout({ children }: { children: React.ReactNode }) {
return <ForumSiteChrome>{children}</ForumSiteChrome>;
}

View File

@@ -0,0 +1,56 @@
import Link from "next/link";
export default function ForumModerationPage() {
return (
<article className="rounded-lg border border-[#3d3228] bg-[#120e0a]/95 p-6 md:p-10">
<header className="border-b border-[#2a2218] pb-6">
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-[#ff9a3c]/60">Void Aggregate · policy</p>
<h1 className="mt-2 font-mono text-2xl font-black text-[#ffcb8a] md:text-3xl">Moderation doctrine</h1>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#b8a995]">
CyberLux models moderation as layered enforcement: ring-scoped janitors, cross-ring mediators, and hub council. The UI may not expose every tool on every onion but the story stays consistent across mirrors.
</p>
</header>
<div className="mt-8 grid gap-6 md:grid-cols-2">
<section className="rounded border border-[#5c4030] p-5">
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-[#c45c26]">Tier A ring freeze</h2>
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
Temporary halt on new replies in one ring during spam bursts. Pinned posts stay visible; submit route shows a banner with estimated thaw window.
</p>
</section>
<section className="rounded border border-[#5c4030] p-5">
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-[#c45c26]">Tier B handle shadow</h2>
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
Posts from a handle sink in ranking but remain addressable by direct link used for cooling-off periods without memory-holing evidence chains.
</p>
</section>
<section className="rounded border border-[#5c4030] p-5">
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-[#c45c26]">Tier C network exile</h2>
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
Session fingerprint tied to abuse pattern; dashboard and submit surfaces reject with signed rationale stub. Reversible only by two-of-three council multisig (diegetic).
</p>
</section>
<section className="rounded border border-[#5c4030] p-5">
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-[#c45c26]">Appeals</h2>
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
Routed through{" "}
<Link href="/support" className="text-[#ff9a3c] underline">
support
</Link>{" "}
with thread id + ring slug. No DMs to individual mods keeps audit trail intact.
</p>
</section>
</div>
<p className="mt-10 font-mono text-[10px] text-[#6b5c4d]">
<Link href="/forum/handbook" className="text-[#ff9a3c] hover:underline">
Handbook
</Link>
{" · "}
<Link href="/forum" className="text-[#ff9a3c] hover:underline">
Front
</Link>
</p>
</article>
);
}

143
app/forum/page.tsx Normal file
View File

@@ -0,0 +1,143 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import ForumFeedToolbar from "@/components/forum/ForumFeedToolbar";
import { ForumPostCard } from "@/components/forum/ForumPostCard";
import { FORUM_RINGS } from "@/lib/forumRings";
import { threadMatchesQuery } from "@/lib/forumFeedFilter";
import {
isPinnedThread,
loadForum,
sortPinnedThreads,
type ForumThread,
} from "@/lib/forumState";
import { sortThreads, type ForumSortMode } from "@/lib/forumSort";
export default function VoidAggregateHomePage() {
const [threads, setThreads] = useState<ForumThread[]>([]);
const [sort, setSort] = useState<ForumSortMode>("hot");
const [ringFilter, setRingFilter] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [hydrated, setHydrated] = useState(false);
const [, setVoteBump] = useState(0);
useEffect(() => {
setThreads(loadForum());
setHydrated(true);
}, []);
const filtered = useMemo(() => {
return threads.filter((t) => {
if (ringFilter !== null && t.topicSlug !== ringFilter) return false;
return threadMatchesQuery(t, search);
});
}, [threads, ringFilter, search]);
const { pinnedList, feedList } = useMemo(() => {
const pinned = sortPinnedThreads(filtered.filter(isPinnedThread));
const rest = filtered.filter((t) => !isPinnedThread(t));
return { pinnedList: pinned, feedList: sortThreads(rest, sort) };
}, [filtered, sort]);
const totalShown = pinnedList.length + feedList.length;
return (
<div className="flex flex-col gap-8 lg:flex-row">
<main className="min-w-0 flex-1 space-y-5">
<ForumFeedToolbar
sort={sort}
onSortChange={setSort}
search={search}
onSearchChange={setSearch}
ringFilter={ringFilter}
onRingChange={setRingFilter}
threadCount={totalShown}
hydrated={hydrated}
/>
{!hydrated ? (
<p className="font-mono text-sm text-[#8a7d6b]">Loading feed</p>
) : totalShown === 0 ? (
<div className="rounded-lg border border-dashed border-[#3d3228] p-10 text-center font-mono text-sm text-[#8a7d6b]">
<p>No threads match.</p>
<p className="mt-2 text-xs text-[#5c5044]">Clear search or pick another ring.</p>
<Link href="/forum/submit" className="mt-4 inline-block text-[#ff9a3c] underline">
Start a thread
</Link>
</div>
) : (
<>
{pinnedList.length ? (
<section className="space-y-2">
<h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.28em] text-[#c45c26]/90">
Pinned · rules & mirrors
</h2>
<div className="space-y-3">
{pinnedList.map((t) => (
<ForumPostCard key={t.id} thread={t} onVoteChange={() => setVoteBump((x) => x + 1)} />
))}
</div>
</section>
) : null}
{feedList.length ? (
<section className="space-y-3">
{pinnedList.length ? (
<h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.28em] text-[#8a7d6b]">
Feed
</h2>
) : null}
{feedList.map((t) => (
<ForumPostCard key={t.id} thread={t} onVoteChange={() => setVoteBump((x) => x + 1)} />
))}
</section>
) : null}
</>
)}
</main>
<aside className="w-full shrink-0 space-y-4 lg:w-72">
<div className="rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-4">
<h3 className="font-mono text-xs font-bold uppercase tracking-widest text-[#ff9a3c]/80">Void Aggregate</h3>
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
Ringed boards each namespace is an <code className="text-[#ff9a3c]">r/name</code> slice. Votes and new threads stay in your
browser profile; seeded &quot;Staff&quot; threads stay read-only so the front page stays legible.
</p>
<Link
href="/forum/submit"
className="mt-4 block w-full rounded border border-[#ff9a3c] bg-[#ff9a3c]/15 py-2 text-center font-mono text-xs font-bold uppercase text-[#ffcb8a] hover:bg-[#ff9a3c]/25"
>
Create post
</Link>
<Link
href="/chatter"
className="mt-3 block w-full rounded border border-[#3d3228] py-2 text-center font-mono text-xs text-[#b8a995] hover:border-[#ff9a3c]/40"
>
Hub chatter archive
</Link>
<Link
href="/account/hidden-services"
className="mt-2 block w-full rounded border border-[#3d3228] py-2 text-center font-mono text-xs text-[#8a7d6b] hover:border-[#ff9a3c]/40"
>
Identity on mirrors
</Link>
</div>
<div className="rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-4">
<h3 className="font-mono text-xs font-bold uppercase tracking-widest text-[#ff9a3c]/80">Rings</h3>
<ol className="mt-3 max-h-[min(50vh,22rem)] space-y-2 overflow-y-auto font-mono text-sm text-[#b8a995] pr-1">
{FORUM_RINGS.map((r) => (
<li key={r.slug} className="flex justify-between gap-2 border-b border-[#2a2218]/80 pb-2 last:border-0">
<Link href={`/forum/ring/${r.slug}`} className="min-w-0 truncate text-[#ffcb8a] hover:underline">
r/{r.shortName}
</Link>
<span className="shrink-0 text-[#8a7d6b]">{r.members > 999 ? `${Math.round(r.members / 1000)}k` : r.members}</span>
</li>
))}
</ol>
</div>
</aside>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,58 @@
import Link from "next/link";
export default function ForumReputationPage() {
return (
<article className="rounded-lg border border-[#3d3228] bg-[#120e0a]/95 p-6 md:p-10">
<header className="border-b border-[#2a2218] pb-6">
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-[#ff9a3c]/60">Void Aggregate · scoring</p>
<h1 className="mt-2 font-mono text-2xl font-black text-[#ffcb8a] md:text-3xl">Reputation &amp; weight</h1>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#b8a995]">
Rings inherit different temperatures: a hot take in one board may score differently than the same post in a research ring. Below is the in-universe model that keeps sorting labels honest.
</p>
</header>
<section className="mt-8 overflow-x-auto">
<table className="w-full border-collapse text-left font-mono text-xs text-[#b8a995]">
<thead>
<tr className="border-b border-[#3d3228] text-[10px] uppercase tracking-wider text-[#8a7d6b]">
<th className="py-2 pr-4">Signal</th>
<th className="py-2 pr-4">Hot sort</th>
<th className="py-2">Top sort</th>
</tr>
</thead>
<tbody className="divide-y divide-[#2a2218]">
<tr>
<td className="py-3 font-bold text-[#ffcb8a]">Upvotes</td>
<td>Decay-weighted over 72h window</td>
<td>Log-scaled lifetime sum</td>
</tr>
<tr>
<td className="py-3 font-bold text-[#ffcb8a]">Replies</td>
<td>Boost if reply author has ring tenure</td>
<td>Cap per thread to prevent brigade loops</td>
</tr>
<tr>
<td className="py-3 font-bold text-[#ffcb8a]">Reports</td>
<td>Immediate soft-hide pending review</td>
<td>Negative prior on future sorts until cleared</td>
</tr>
</tbody>
</table>
</section>
<p className="mt-8 text-sm leading-relaxed text-[#8a7d6b]">
New handles start with neutral prior; verified purchases from the Candy Shop (same session) add a small trust bump in commerce-adjacent rings only never globally, to avoid wallet bragging as universal clout.
</p>
<p className="mt-10 font-mono text-[10px] text-[#6b5c4d]">
<Link href="/forum/handbook" className="text-[#ff9a3c] hover:underline">
Handbook
</Link>
{" · "}
<Link href="/forum/moderation" className="text-[#ff9a3c] hover:underline">
Moderation
</Link>
</p>
</article>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import ForumFeedToolbar from "@/components/forum/ForumFeedToolbar";
import { ForumPostCard } from "@/components/forum/ForumPostCard";
import { threadMatchesQuery } from "@/lib/forumFeedFilter";
import { getRing } from "@/lib/forumRings";
import {
isPinnedThread,
loadForum,
sortPinnedThreads,
type ForumThread,
} from "@/lib/forumState";
import { sortThreads, type ForumSortMode } from "@/lib/forumSort";
export default function VoidAggregateRingPage() {
const params = useParams();
const slug = typeof params.slug === "string" ? params.slug : "";
const ring = getRing(slug);
const [threads, setThreads] = useState<ForumThread[]>([]);
const [sort, setSort] = useState<ForumSortMode>("hot");
const [search, setSearch] = useState("");
const [hydrated, setHydrated] = useState(false);
const [, setVoteBump] = useState(0);
useEffect(() => {
setThreads(loadForum());
setHydrated(true);
}, []);
const filtered = useMemo(() => {
if (!ring) return [];
return threads
.filter((t) => t.topicSlug === ring.slug)
.filter((t) => threadMatchesQuery(t, search));
}, [threads, ring, search]);
const { pinnedList, feedList } = useMemo(() => {
const pinned = sortPinnedThreads(filtered.filter(isPinnedThread));
const rest = filtered.filter((t) => !isPinnedThread(t));
return { pinnedList: pinned, feedList: sortThreads(rest, sort) };
}, [filtered, sort]);
const totalShown = pinnedList.length + feedList.length;
if (!ring) {
return (
<div className="rounded-lg border border-[#c45c26]/40 bg-[#120e0a] p-8 text-center font-mono">
<p className="text-[#ffcb8a]">Unknown ring · /r/{slug}</p>
<Link href="/forum" className="mt-4 inline-block text-sm text-[#ff9a3c] underline">
front page
</Link>
</div>
);
}
return (
<div className="space-y-6">
<header className="rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-6">
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-[#ff9a3c]/70">r/{ring.shortName}</p>
<h1 className="mt-2 font-mono text-2xl font-black text-[#ffcb8a] md:text-3xl">{ring.title}</h1>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#b8a995]">{ring.about}</p>
<div className="mt-4 flex flex-wrap gap-4 font-mono text-[11px] text-[#8a7d6b]">
<span>
<strong className="text-[#ffcb8a]/90">{ring.members.toLocaleString()}</strong> members
</span>
<span>
<strong className="text-[#39ff14]/80">{ring.activeNow.toLocaleString()}</strong> est. online
</span>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<Link
href={`/forum/submit?ring=${encodeURIComponent(ring.slug)}`}
className="rounded border border-[#ff9a3c] bg-[#ff9a3c]/15 px-4 py-2 font-mono text-xs font-bold uppercase text-[#ffcb8a] hover:bg-[#ff9a3c]/25"
>
Post in r/{ring.shortName}
</Link>
<Link href="/forum" className="rounded border border-[#3d3228] px-4 py-2 font-mono text-xs text-[#b8a995] hover:border-[#ff9a3c]/40">
All rings
</Link>
</div>
</header>
<ForumFeedToolbar
sort={sort}
onSortChange={setSort}
search={search}
onSearchChange={setSearch}
ringFilter={ring.slug}
threadCount={totalShown}
hydrated={hydrated}
extraRight={
<Link href="/forum" className="rounded px-2 py-1 text-[#8a7d6b] hover:text-[#ffcb8a]">
Global feed
</Link>
}
/>
{!hydrated ? (
<p className="font-mono text-sm text-[#8a7d6b]">Loading</p>
) : totalShown === 0 ? (
<p className="rounded-lg border border-dashed border-[#3d3228] p-8 text-center font-mono text-sm text-[#8a7d6b]">
No threads match in r/{ring.shortName}.{" "}
<Link href={`/forum/submit?ring=${encodeURIComponent(ring.slug)}`} className="text-[#ff9a3c] underline">
Start one
</Link>
.
</p>
) : (
<div className="space-y-5">
{pinnedList.length ? (
<section className="space-y-2">
<h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.28em] text-[#c45c26]/90">
Pinned here
</h2>
<div className="space-y-3">
{pinnedList.map((t) => (
<ForumPostCard key={t.id} thread={t} onVoteChange={() => setVoteBump((x) => x + 1)} />
))}
</div>
</section>
) : null}
{feedList.length ? (
<section className="space-y-3">
{pinnedList.length ? (
<h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.28em] text-[#8a7d6b]">Threads</h2>
) : null}
{feedList.map((t) => (
<ForumPostCard key={t.id} thread={t} onVoteChange={() => setVoteBump((x) => x + 1)} />
))}
</section>
) : null}
</div>
)}
</div>
);
}

105
app/forum/submit/page.tsx Normal file
View File

@@ -0,0 +1,105 @@
"use client";
import { FormEvent, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAccount } from "@/contexts/AccountContext";
import { FORUM_RINGS } from "@/lib/forumRings";
import { addThread } from "@/lib/forumState";
export default function VoidAggregateSubmitPage() {
const router = useRouter();
const { user, hydrated } = useAccount();
const prefilledAuthor = useRef(false);
const [topicSlug, setTopicSlug] = useState(FORUM_RINGS[0]!.slug);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [author, setAuthor] = useState("");
useEffect(() => {
if (!hydrated || !user || prefilledAuthor.current) return;
setAuthor(user.displayName);
prefilledAuthor.current = true;
}, [hydrated, user]);
useEffect(() => {
if (typeof window === "undefined") return;
const q = new URLSearchParams(window.location.search).get("ring");
if (q && FORUM_RINGS.some((r) => r.slug === q)) setTopicSlug(q);
}, []);
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (!title.trim() || !body.trim()) return;
const t = addThread({
topicSlug,
title,
body,
author,
});
router.push(`/forum/post/${t.id}`);
};
return (
<div className="mx-auto max-w-2xl">
<Link href="/forum" className="font-mono text-[11px] uppercase tracking-widest text-[#ff9a3c] hover:text-[#ffcb8a]">
Back to feed
</Link>
<h2 className="mt-6 font-mono text-xl font-bold text-[#ffcb8a]">Create submission</h2>
<p className="mt-2 text-sm text-[#b8a995]">Pick a ring, write a title, drop your text. Unsigned posts are untrusted.</p>
<form onSubmit={onSubmit} className="mt-8 space-y-5 rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-6">
<div>
<label className="block font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/80">Ring</label>
<select
value={topicSlug}
onChange={(e) => setTopicSlug(e.target.value)}
className="mt-2 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"
>
{FORUM_RINGS.map((r) => (
<option key={r.slug} value={r.slug} className="bg-[#0d0a07]">
r/{r.shortName} {r.title}
</option>
))}
</select>
</div>
<div>
<label className="block font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/80">Display name</label>
<input
value={author}
onChange={(e) => setAuthor(e.target.value)}
className="mt-2 w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] placeholder:text-[#5c5044] focus:border-[#ff9a3c] focus:outline-none"
placeholder="Alias"
/>
</div>
<div>
<label className="block font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/80">Title</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
required
className="mt-2 w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] placeholder:text-[#5c5044] focus:border-[#ff9a3c] focus:outline-none"
placeholder="Be specific — searchable titles get better replies"
/>
</div>
<div>
<label className="block font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/80">Body</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
required
rows={8}
className="mt-2 w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] placeholder:text-[#5c5044] focus:border-[#ff9a3c] focus:outline-none"
placeholder="Context, logs (redact IPs), PGP block optional…"
/>
</div>
<button
type="submit"
className="w-full rounded border-2 border-[#ff9a3c] bg-[#ff9a3c] py-3 font-mono text-sm font-black uppercase tracking-wider text-black hover:bg-[#ffcb8a]"
>
Publish
</button>
</form>
</div>
);
}

19
app/forum/t/[id]/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
/** Old thread URLs → /forum/post/[id] */
export default function LegacyForumThreadRedirect() {
const params = useParams();
const router = useRouter();
const id = typeof params.id === "string" ? params.id : "";
useEffect(() => {
if (id) router.replace(`/forum/post/${id}`);
}, [id, router]);
return (
<p className="font-mono text-sm text-[#8a7d6b]">Redirecting</p>
);
}