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:
101
components/forum/ForumFeedToolbar.tsx
Normal file
101
components/forum/ForumFeedToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
components/forum/ForumPostCard.tsx
Normal file
74
components/forum/ForumPostCard.tsx
Normal 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`;
|
||||
}
|
||||
167
components/forum/ForumSiteChrome.tsx
Normal file
167
components/forum/ForumSiteChrome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
components/forum/ThreadVoteColumn.tsx
Normal file
64
components/forum/ThreadVoteColumn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user