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,101 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { FORUM_RINGS } from "@/lib/forumRings";
import type { ForumSortMode } from "@/lib/forumSort";
type Props = {
sort: ForumSortMode;
onSortChange: (m: ForumSortMode) => void;
search: string;
onSearchChange: (q: string) => void;
/** When set, show ring chips + `null` = all rings. Omit `onRingChange` to hide chips (ring sub-page). */
ringFilter: string | null;
onRingChange?: (slug: string | null) => void;
extraRight?: ReactNode;
threadCount?: number;
hydrated?: boolean;
};
export default function ForumFeedToolbar({
sort,
onSortChange,
search,
onSearchChange,
ringFilter,
onRingChange,
extraRight,
threadCount,
hydrated,
}: Props) {
return (
<div className="space-y-4 border-b border-[#3d3228] pb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label className="min-w-0 flex-1 font-mono text-[10px] uppercase tracking-wider text-[#8a7d6b]">
<span className="mb-1 block text-[#ff9a3c]/70">Search threads</span>
<input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Title, body, author, ring…"
className="w-full rounded border border-[#3d3228] bg-[#0d0a07] px-3 py-2 font-mono text-sm text-[#e7d9c8] placeholder:text-[#5c5044] focus:border-[#ff9a3c]/60 focus:outline-none"
/>
</label>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{(["hot", "new", "top"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => onSortChange(m)}
className={`rounded px-3 py-1.5 font-mono text-[11px] font-bold uppercase tracking-wider ${
sort === m ? "bg-[#c45c26]/40 text-[#ffcb8a]" : "text-[#8a7d6b] hover:text-[#ffcb8a]"
}`}
>
{m}
</button>
))}
</div>
</div>
{onRingChange ? (
<div className="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-wider">
<span className="text-[#5c5044]">Ring</span>
<button
type="button"
onClick={() => onRingChange(null)}
className={`rounded px-2.5 py-1 font-bold ${
ringFilter === null ? "bg-[#3d3228] text-[#ffcb8a]" : "text-[#8a7d6b] hover:text-[#ffcb8a]"
}`}
>
all
</button>
{FORUM_RINGS.map((r) => (
<button
key={r.slug}
type="button"
onClick={() => onRingChange(r.slug)}
className={`rounded px-2.5 py-1 font-bold ${
ringFilter === r.slug ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "text-[#8a7d6b] hover:text-[#ffcb8a]"
}`}
>
r/{r.shortName}
</button>
))}
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2 font-mono text-[10px] text-[#5c5044]">
<span>{hydrated === false ? "…" : threadCount !== undefined ? `${threadCount} threads` : null}</span>
<div className="flex flex-wrap items-center gap-2">
{extraRight}
<Link
href="/forum/submit"
className="rounded border border-[#ff9a3c]/60 px-3 py-1 font-bold uppercase text-[#ffcb8a] hover:bg-[#ff9a3c]/15"
>
+ New post
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import Link from "next/link";
import { formatRingLabel } from "@/lib/forumRings";
import { displayScore, isPinnedThread, isReadOnlyThread, type ForumThread } from "@/lib/forumState";
import { ThreadVoteColumn } from "@/components/forum/ThreadVoteColumn";
export function ForumPostCard({
thread,
onVoteChange,
}: {
thread: ForumThread;
/** Fire when vote changes so list meta (score) re-renders from localStorage */
onVoteChange?: () => void;
}) {
const pinned = isPinnedThread(thread);
const locked = isReadOnlyThread(thread.id);
const score = displayScore(thread);
return (
<div
className={`flex gap-3 rounded-lg border bg-[#120e0a]/90 p-3 transition-colors ${
pinned
? "border-[#c45c26]/65 shadow-[0_0_0_1px_rgba(196,92,38,0.12)] hover:border-[#ff9a3c]/55"
: "border-[#3d3228] hover:border-[#c45c26]/45"
}`}
>
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
<ThreadVoteColumn thread={thread} dense onVoteChange={onVoteChange} />
</div>
<Link href={`/forum/post/${thread.id}`} className="min-w-0 flex-1 outline-none">
<div className="flex flex-wrap items-center gap-2">
{pinned ? (
<span className="rounded bg-[#c45c26]/35 px-2 py-0.5 font-mono text-[9px] font-black uppercase tracking-widest text-[#ffcb8a]">
Pinned
</span>
) : null}
{locked ? (
<span className="rounded border border-[#5c5044] px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[#8a7d6b]">
Staff
</span>
) : null}
</div>
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[#8a7d6b]">
<span className="text-[#ff9a3c]/90">{formatRingLabel(thread.topicSlug)}</span>
<span className="mx-2 text-[#3d3228]">·</span>
<span>{thread.author || "Anonymous"}</span>
<span className="mx-2 text-[#3d3228]">·</span>
<span>{formatAgo(thread.ts)}</span>
<span className="mx-2 text-[#3d3228]">·</span>
<span className="tabular-nums text-[#ffcb8a]/80">{score} pts</span>
</p>
<h3 className="mt-1 font-mono text-base font-bold leading-snug text-[#ffcb8a] hover:underline md:text-lg">
{thread.title}
</h3>
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-[#b8a995]">{thread.body}</p>
<div className="mt-3 flex flex-wrap gap-3 font-mono text-[11px] text-[#8a7d6b]">
<span className="rounded border border-[#3d3228] px-2 py-0.5">{thread.replies.length} comments</span>
</div>
</Link>
</div>
);
}
function formatAgo(ts: number): string {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}

View File

@@ -0,0 +1,167 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import LexiconStrip from "@/components/site/LexiconStrip";
import { useAccount } from "@/contexts/AccountContext";
import { FORUM_RINGS, getRing } from "@/lib/forumRings";
import { FORUM_ATMOSPHERE_TAGS } from "@/lib/cyberluxZoneLexicon";
const SITE_NAME = "Void Aggregate";
const SITE_TAGLINE = "distributed rumor ledger";
function Scanlines() {
return (
<div
className="pointer-events-none fixed inset-0 z-0 opacity-[0.1]"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.5) 3px)",
}}
/>
);
}
export default function ForumSiteChrome({ children }: { children: React.ReactNode }) {
const { user, hydrated } = useAccount();
const pathname = usePathname();
const ringMatch = pathname?.match(/^\/forum\/ring\/([^/]+)/);
const activeSlug = ringMatch?.[1] ?? null;
const activeRing = activeSlug ? getRing(activeSlug) : null;
return (
<div className="min-h-screen bg-[#070604] text-[#e7d9c8]">
<Scanlines />
<header className="sticky top-0 z-20 border-b border-[#c45c26]/30 bg-[#0d0a07]/95 backdrop-blur-md">
<div className="mx-auto flex max-w-6xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
<Link href="/forum" className="group block">
<p className="text-[9px] font-mono uppercase tracking-[0.45em] text-[#ff9a3c]/70">{SITE_TAGLINE}</p>
<h1 className="font-mono text-xl font-black tracking-tight text-[#ffcb8a] md:text-2xl">
{SITE_NAME}
<span className="ml-2 text-[10px] font-normal opacity-50 group-hover:opacity-80">v3</span>
</h1>
</Link>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-mono uppercase tracking-wider">
<span className="hidden rounded border border-[#5c4030] bg-[#1a1510] px-3 py-1.5 text-[#b8a995] sm:inline">
onion forum
</span>
<Link
href="/forum/submit"
className="rounded border border-[#ff9a3c] bg-[#ff9a3c]/15 px-3 py-1.5 font-bold text-[#ffcb8a] hover:bg-[#ff9a3c]/25"
>
+ Submit
</Link>
{hydrated && user ? (
<Link href="/dashboard" className="rounded border border-[#2a1810] px-3 py-1.5 text-[#c4a574] hover:border-[#ff9a3c]/45">
Dashboard
</Link>
) : (
<>
<Link href="/sign-in" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
Sign in
</Link>
<Link
href="/sign-up"
className="rounded border border-[#ff9a3c]/50 bg-[#ff9a3c]/10 px-3 py-1.5 text-[#ffcb8a] hover:bg-[#ff9a3c]/20"
>
Sign up
</Link>
<Link
href="/account/hidden-services"
className="rounded border border-[#5c4030] px-3 py-1.5 text-[#9a8f7d] hover:border-[#ff9a3c]/50"
>
Mirrors
</Link>
</>
)}
<Link href="/" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
Hub
</Link>
<Link href="/exchange" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
Exchange
</Link>
<Link href="/barter" className="rounded border border-[#1a3d24] bg-[#0a120d] px-3 py-1.5 text-[#7cfc9a] hover:border-[#39ff14]/40">
Barter
</Link>
<Link href="/hidden-wiki" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
Wiki
</Link>
<Link
href="/darknet-atlas"
className="rounded border border-[#1e3a5f] bg-[#0a1520] px-3 py-1.5 text-[#7eb8ff] hover:border-[#38bdf8]/50"
>
Atlas
</Link>
<Link
href="/search"
className="rounded border border-[#0d2818] bg-[#050a07] px-3 py-1.5 text-[#6bdc8e] hover:border-[#00ff41]/35"
>
Crawler
</Link>
<Link href="/chatter" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
Hub chatter
</Link>
</div>
</div>
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-2 border-t border-[#2a2218] px-4 py-2 text-[10px] font-mono text-[#8a7d6b]">
<span className="uppercase tracking-widest text-[#c45c26]/90">Depth</span>
<Link
href="/forum/handbook"
className={`rounded px-2 py-0.5 ${pathname?.startsWith("/forum/handbook") ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
>
handbook
</Link>
<Link
href="/forum/moderation"
className={`rounded px-2 py-0.5 ${pathname?.startsWith("/forum/moderation") ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
>
moderation
</Link>
<Link
href="/forum/reputation"
className={`rounded px-2 py-0.5 ${pathname?.startsWith("/forum/reputation") ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
>
reputation
</Link>
<span className="mx-1 hidden text-[#3d3228] sm:inline">|</span>
<span className="uppercase tracking-widest text-[#ff9a3c]/80">Rings</span>
<Link
href="/forum"
className={`rounded px-2 py-0.5 ${!activeSlug && pathname === "/forum" ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
>
front page
</Link>
{FORUM_RINGS.map((r) => (
<Link
key={r.slug}
href={`/forum/ring/${r.slug}`}
className={`rounded px-2 py-0.5 ${activeSlug === r.slug ? "bg-[#c45c26]/35 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
>
r/{r.shortName}
</Link>
))}
</div>
<div className="mx-auto max-w-6xl border-t border-[#2a2218] px-4 text-[#6b5c4d]">
<LexiconStrip label="Board environment · signal lexicon" tags={FORUM_ATMOSPHERE_TAGS} className="border-[#3d3228]" />
</div>
</header>
<div className="relative z-10 mx-auto max-w-6xl px-4 py-8">
{activeRing ? (
<div className="mb-8 rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-5">
<p className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#ff9a3c]/70">
r/{activeRing.shortName}
</p>
<h2 className="mt-1 font-mono text-lg font-bold text-[#ffcb8a]">{activeRing.title}</h2>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-[#b8a995]">{activeRing.about}</p>
<div className="mt-4 flex flex-wrap gap-4 font-mono text-[11px] text-[#8a7d6b]">
<span>{activeRing.members.toLocaleString()} subscribers</span>
<span>{activeRing.activeNow.toLocaleString()} reading now</span>
</div>
</div>
) : null}
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useCallback, useState } from "react";
import type { ForumThread } from "@/lib/forumState";
import {
displayScore,
getVoteForThread,
setVoteForThread,
type VoteChoice,
} from "@/lib/forumState";
export function ThreadVoteColumn({
thread,
dense,
onVoteChange,
}: {
thread: ForumThread;
dense?: boolean;
onVoteChange?: () => void;
}) {
const [tick, setTick] = useState(0);
const choice = getVoteForThread(thread.id);
const score = displayScore(thread);
const apply = useCallback(
(dir: 1 | -1) => {
const cur = getVoteForThread(thread.id);
let next: VoteChoice = dir;
if (cur === dir) next = 0;
setVoteForThread(thread.id, next);
setTick((t) => t + 1);
onVoteChange?.();
},
[thread.id, onVoteChange],
);
void tick;
const btn = dense ? "px-1 py-0.5 text-[10px]" : "px-1.5 py-1 text-xs";
return (
<div
className={`flex flex-col items-center rounded border border-[#3d3228] bg-[#0d0a07] font-mono ${dense ? "min-w-[44px] py-1" : "min-w-[52px] py-2"}`}
>
<button
type="button"
aria-label="Upvote"
onClick={() => apply(1)}
className={`${btn} leading-none text-[#8a7d6b] hover:text-[#ff9a3c] ${choice === 1 ? "text-[#ff9a3c]" : ""}`}
>
</button>
<span className="text-[11px] font-bold tabular-nums text-[#ffcb8a]">{score}</span>
<button
type="button"
aria-label="Downvote"
onClick={() => apply(-1)}
className={`${btn} leading-none text-[#8a7d6b] hover:text-[#6b9fff] ${choice === -1 ? "text-[#6b9fff]" : ""}`}
>
</button>
</div>
);
}