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
346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
import { categoryToSlug } from "@/lib/forumRings";
|
||
import { SHOP_CHATTER_THREADS } from "@/lib/shopChatterThreads";
|
||
|
||
export type ForumReply = {
|
||
id: string;
|
||
author: string;
|
||
body: string;
|
||
ts: number;
|
||
};
|
||
|
||
export type ForumThread = {
|
||
id: string;
|
||
/** Ring / sub-board slug, e.g. opsec */
|
||
topicSlug: string;
|
||
/** @deprecated legacy display — prefer topicSlug + formatRingLabel */
|
||
category?: string;
|
||
title: string;
|
||
author: string;
|
||
body: string;
|
||
ts: number;
|
||
replies: ForumReply[];
|
||
/** Seeded score before user votes */
|
||
baseScore?: number;
|
||
};
|
||
|
||
const KEY = "cyberlux-forum-v2";
|
||
const LEGACY_KEY = "cyberlux-forum-v1";
|
||
const VOTE_KEY = "cyberlux-forum-votechoice-v1";
|
||
|
||
export type VoteChoice = -1 | 0 | 1;
|
||
|
||
function uid(): string {
|
||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||
return `t_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||
}
|
||
|
||
function normalizeThread(raw: ForumThread): ForumThread {
|
||
const topicSlug = raw.topicSlug?.trim() || categoryToSlug(raw.category);
|
||
const baseScore = typeof raw.baseScore === "number" ? raw.baseScore : 1;
|
||
const { category, ...rest } = raw;
|
||
return {
|
||
...rest,
|
||
topicSlug,
|
||
baseScore,
|
||
...(category ? { category } : {}),
|
||
};
|
||
}
|
||
|
||
const SHOP_CHATTER_FORUM: ForumThread[] = SHOP_CHATTER_THREADS.map((t) =>
|
||
normalizeThread({
|
||
id: t.id,
|
||
topicSlug: t.topicSlug,
|
||
title: t.title,
|
||
author: t.author,
|
||
body: t.body,
|
||
ts: t.ts,
|
||
baseScore: t.baseScore,
|
||
replies: t.replies,
|
||
}),
|
||
);
|
||
|
||
const seedThreads: ForumThread[] = [
|
||
{
|
||
id: "seed-1",
|
||
topicSlug: "opsec",
|
||
category: "Security",
|
||
author: "GhostLayer",
|
||
title: "Tor browser hardening checklist",
|
||
body: "Post your best practices for compartmentalization and DNS leaks.",
|
||
ts: Date.now() - 86400000 * 3,
|
||
baseScore: 214,
|
||
replies: [
|
||
{
|
||
id: "r1",
|
||
author: "NodeOp",
|
||
body: "Never mix clearnet VPN identity with onion circuits on the same profile.",
|
||
ts: Date.now() - 86400000 * 2,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "seed-2",
|
||
topicSlug: "markets",
|
||
category: "Market",
|
||
author: "PlainVendor",
|
||
title: "Reputation threads — read before you buy",
|
||
body: "Link proof of prior deals. No PGP = no trust.",
|
||
ts: Date.now() - 86400000,
|
||
baseScore: 892,
|
||
replies: [],
|
||
},
|
||
];
|
||
|
||
const npcThreads: ForumThread[] = [
|
||
{
|
||
id: "npc-pinned-rules",
|
||
topicSlug: "lounge",
|
||
category: "Off-topic",
|
||
author: "STAFF",
|
||
title: "PINNED — Escrow rules & mirror verification",
|
||
body:
|
||
"All first-time vendor deals route through platform escrow. Do not finalize early.\n\n" +
|
||
"Before you load any .onion, cross-check the hostname against the signed mirror list on the main link. " +
|
||
"If the signature does not verify, assume phishing.\n\n" +
|
||
"No begging, no shill chains without proof.",
|
||
ts: Date.now() - 86400000 * 21,
|
||
baseScore: 4_021,
|
||
replies: [
|
||
{
|
||
id: "npc-pinned-rules-r1",
|
||
author: "ModQueue",
|
||
body: "Reporting impersonation: include the exact onion string and a signed message if you have one.",
|
||
ts: Date.now() - 86400000 * 20,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "npc-mirror-check",
|
||
topicSlug: "reputation",
|
||
category: "Reputation",
|
||
author: "watchtower_09",
|
||
title: "Mirror list still matching signed post from last week?",
|
||
body:
|
||
"Cross-posting my onion fingerprint here for sanity check — want to be sure I’m not on a clone before I top up wallet credit.",
|
||
ts: Date.now() - 86400000 * 5,
|
||
baseScore: 1_876,
|
||
replies: [
|
||
{
|
||
id: "npc-mirror-check-r1",
|
||
author: "PGPOrBust",
|
||
body:
|
||
"Ran gpg --verify on the hub canary + mirror block. Three v3 hosts, checksums line up. You’re good.",
|
||
ts: Date.now() - 86400000 * 5 + 3600000,
|
||
},
|
||
{
|
||
id: "npc-mirror-check-r2",
|
||
author: "EU_buyer",
|
||
body:
|
||
"Been routing orders through the main hub for a couple months. Comms lag on weekends but escrow never ghosted me.",
|
||
ts: Date.now() - 86400000 * 4,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "npc-volume-banter",
|
||
topicSlug: "markets",
|
||
category: "Market",
|
||
author: "ledgerline",
|
||
title: "Anyone else seeing steady checkout traffic on the hub?",
|
||
body:
|
||
"Not asking for numbers — just weird how quiet the FUD threads are when the site’s been up stable. Either the complainers aged out or liquidity actually picked up.",
|
||
ts: Date.now() - 86400000 * 2,
|
||
baseScore: 3_409,
|
||
replies: [
|
||
{
|
||
id: "npc-volume-banter-r1",
|
||
author: "quietBag",
|
||
body:
|
||
"Big spenders don’t brag in public. You’ll see it in voucher turnover and vendor queue times.",
|
||
ts: Date.now() - 86400000 * 2 + 7200000,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "npc-ui-thread",
|
||
topicSlug: "tech",
|
||
category: "Tech",
|
||
author: "saltfade",
|
||
title: "New neon skin on main link — performance feels tighter",
|
||
body:
|
||
"Old build used to hitch on the product grid. Whatever they shipped last cycle, Lighthouse numbers on my side look cleaner. Anyone benchmark on low-RAM tails?",
|
||
ts: Date.now() - 86400000,
|
||
baseScore: 667,
|
||
replies: [
|
||
{
|
||
id: "npc-ui-thread-r1",
|
||
author: "tab_hoarder",
|
||
body: "Same. Also the checkout step doesn’t double-fire submit anymore — small but huge.",
|
||
ts: Date.now() - 86400000 + 1800000,
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
const READONLY_THREAD_IDS = new Set(
|
||
[...seedThreads, ...npcThreads, ...SHOP_CHATTER_FORUM].map((t) => t.id),
|
||
);
|
||
|
||
function mergeSystemThreads(userThreads: ForumThread[]): ForumThread[] {
|
||
const have = new Set(userThreads.map((t) => t.id));
|
||
const systemPools = [...npcThreads, ...SHOP_CHATTER_FORUM].map(normalizeThread);
|
||
const extra = systemPools.filter((t) => !have.has(t.id));
|
||
const merged = [...extra, ...userThreads.map(normalizeThread)];
|
||
merged.sort((a, b) => b.ts - a.ts);
|
||
return merged;
|
||
}
|
||
|
||
function migrateLegacyStorage(): ForumThread[] | null {
|
||
if (typeof window === "undefined") return null;
|
||
try {
|
||
const raw = localStorage.getItem(LEGACY_KEY);
|
||
if (!raw) return null;
|
||
const v = JSON.parse(raw) as ForumThread[];
|
||
if (!Array.isArray(v)) return null;
|
||
localStorage.removeItem(LEGACY_KEY);
|
||
return v.map((t) => normalizeThread({ ...t, topicSlug: t.topicSlug || categoryToSlug(t.category) }));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function loadRawUserThreads(): ForumThread[] {
|
||
if (typeof window === "undefined") return [];
|
||
const migrated = migrateLegacyStorage();
|
||
if (migrated?.length) {
|
||
const clean = migrated.filter((t) => t && typeof t.id === "string" && !READONLY_THREAD_IDS.has(t.id));
|
||
if (clean.length) localStorage.setItem(KEY, JSON.stringify(clean));
|
||
}
|
||
try {
|
||
const raw = localStorage.getItem(KEY);
|
||
if (!raw) return [];
|
||
const v = JSON.parse(raw) as ForumThread[];
|
||
if (!Array.isArray(v)) return [];
|
||
return v
|
||
.filter((t) => t && typeof t.id === "string" && !READONLY_THREAD_IDS.has(t.id))
|
||
.map(normalizeThread);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export function loadForum(): ForumThread[] {
|
||
if (typeof window === "undefined") {
|
||
return mergeSystemThreads(seedThreads.map(normalizeThread));
|
||
}
|
||
const user = loadRawUserThreads();
|
||
const base = user.length ? user : seedThreads.map(normalizeThread);
|
||
return mergeSystemThreads(base);
|
||
}
|
||
|
||
export function saveForum(threads: ForumThread[]): void {
|
||
if (typeof window === "undefined") return;
|
||
const persist = threads.filter((t) => !READONLY_THREAD_IDS.has(t.id)).map(normalizeThread);
|
||
localStorage.setItem(KEY, JSON.stringify(persist));
|
||
}
|
||
|
||
export function addThread(
|
||
t: Omit<ForumThread, "id" | "ts" | "replies"> & { replies?: ForumReply[] },
|
||
): ForumThread {
|
||
const thread: ForumThread = normalizeThread({
|
||
id: uid(),
|
||
ts: Date.now(),
|
||
replies: t.replies ?? [],
|
||
topicSlug: t.topicSlug,
|
||
category: t.category,
|
||
title: t.title.trim(),
|
||
author: t.author.trim() || "Anonymous",
|
||
body: t.body.trim(),
|
||
baseScore: 1,
|
||
});
|
||
saveForum([thread, ...loadRawUserThreads()]);
|
||
return thread;
|
||
}
|
||
|
||
export function addReply(threadId: string, author: string, body: string): boolean {
|
||
if (READONLY_THREAD_IDS.has(threadId)) return false;
|
||
const trimmed = body.trim();
|
||
if (!trimmed) return false;
|
||
const user = loadRawUserThreads();
|
||
const i = user.findIndex((x) => x.id === threadId);
|
||
if (i < 0) return false;
|
||
const reply: ForumReply = {
|
||
id: uid(),
|
||
author: author.trim() || "Anonymous",
|
||
body: trimmed,
|
||
ts: Date.now(),
|
||
};
|
||
const next = [...user];
|
||
next[i] = { ...next[i], replies: [...next[i].replies, reply] };
|
||
saveForum(next);
|
||
return true;
|
||
}
|
||
|
||
export function getThread(id: string): ForumThread | undefined {
|
||
const hit = loadForum().find((t) => t.id === id);
|
||
return hit ? normalizeThread(hit) : undefined;
|
||
}
|
||
|
||
function loadVoteMap(): Record<string, VoteChoice> {
|
||
if (typeof window === "undefined") return {};
|
||
try {
|
||
const raw = localStorage.getItem(VOTE_KEY);
|
||
if (!raw) return {};
|
||
const o = JSON.parse(raw) as Record<string, number>;
|
||
const out: Record<string, VoteChoice> = {};
|
||
for (const [k, v] of Object.entries(o)) {
|
||
if (v === -1 || v === 0 || v === 1) out[k] = v;
|
||
}
|
||
return out;
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function saveVoteMap(m: Record<string, VoteChoice>): void {
|
||
if (typeof window === "undefined") return;
|
||
localStorage.setItem(VOTE_KEY, JSON.stringify(m));
|
||
}
|
||
|
||
export function getVoteForThread(threadId: string): VoteChoice {
|
||
return loadVoteMap()[threadId] ?? 0;
|
||
}
|
||
|
||
/** Set user vote (-1 down, 0 clear, 1 up). Reddit-style toggle handled in UI. */
|
||
export function setVoteForThread(threadId: string, next: VoteChoice): void {
|
||
const m = loadVoteMap();
|
||
m[threadId] = next;
|
||
saveVoteMap(m);
|
||
}
|
||
|
||
export function displayScore(t: ForumThread): number {
|
||
const base = t.baseScore ?? 1;
|
||
const v = typeof window !== "undefined" ? getVoteForThread(t.id) : 0;
|
||
return base + v;
|
||
}
|
||
|
||
/** System-seeded threads — users cannot append replies (open a new thread instead). */
|
||
export function isReadOnlyThread(threadId: string): boolean {
|
||
return READONLY_THREAD_IDS.has(threadId);
|
||
}
|
||
|
||
const PINNED_THREAD_IDS = new Set(["npc-pinned-rules"]);
|
||
|
||
export function isPinnedThread(t: ForumThread): boolean {
|
||
return PINNED_THREAD_IDS.has(t.id) || /^PINNED\b/i.test(t.title.trim());
|
||
}
|
||
|
||
export function sortPinnedThreads(list: ForumThread[]): ForumThread[] {
|
||
return [...list].sort((a, b) => {
|
||
const pa = PINNED_THREAD_IDS.has(a.id) ? 0 : 1;
|
||
const pb = PINNED_THREAD_IDS.has(b.id) ? 0 : 1;
|
||
if (pa !== pb) return pa - pb;
|
||
return b.ts - a.ts;
|
||
});
|
||
}
|