feat(board): Lightning-paid message board with LNbits and admin moderation
Add public /board flow: create invoice, webhook + confirm reconciliation, list active messages, likes (Nostr), zap fallbacks. Admin table for hide/delete. Include LNbits webhook body normalization (double-encoded JSON), POST /api/messages/confirm/:hash, and root npm db:push script. Prisma models for pending invoices and board messages. Made-with: Cursor
This commit is contained in:
146
frontend/app/admin/messages/page.tsx
Normal file
146
frontend/app/admin/messages/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Eye, EyeOff, Trash2 } from "lucide-react";
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
paymentHash: string;
|
||||
content: string;
|
||||
authorName: string;
|
||||
pubkey: string | null;
|
||||
satsPaid: number;
|
||||
status: string;
|
||||
likeCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function AdminBoardMessagesPage() {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getAdminBoardMessages();
|
||||
setRows(data);
|
||||
setError("");
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const toggleHide = async (id: string) => {
|
||||
try {
|
||||
await api.hideBoardMessage(id);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Hide failed");
|
||||
}
|
||||
};
|
||||
|
||||
const softDelete = async (id: string) => {
|
||||
if (!confirm("Mark this message as deleted? It will disappear from the public board.")) return;
|
||||
try {
|
||||
await api.deleteBoardMessage(id);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[40vh]">
|
||||
<p className="text-on-surface/50">Loading board messages…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-on-surface">Message board</h1>
|
||||
<p className="text-on-surface-variant text-sm max-w-2xl">
|
||||
Lightning-paid public messages. Hide toggles visibility on the site; delete marks a row as removed
|
||||
without dropping history.
|
||||
</p>
|
||||
{error && <p className="text-error text-sm">{error}</p>}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-outline-variant/30">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-surface-container-high text-on-surface-variant uppercase text-xs">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold">Content</th>
|
||||
<th className="px-4 py-3 font-semibold">Author</th>
|
||||
<th className="px-4 py-3 font-semibold">Sats</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
<th className="px-4 py-3 font-semibold">Date</th>
|
||||
<th className="px-4 py-3 font-semibold w-40">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-outline-variant/20">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="bg-surface-container-low hover:bg-surface-container/80">
|
||||
<td className="px-4 py-3 max-w-md">
|
||||
<p className="text-on-surface line-clamp-3 whitespace-pre-wrap break-words">{r.content}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-on-surface whitespace-nowrap">{r.authorName}</td>
|
||||
<td className="px-4 py-3 font-mono text-primary">{r.satsPaid}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={
|
||||
r.status === "active"
|
||||
? "text-green-600 font-medium"
|
||||
: r.status === "hidden"
|
||||
? "text-amber-600 font-medium"
|
||||
: "text-on-surface-variant"
|
||||
}
|
||||
>
|
||||
{r.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-on-surface-variant whitespace-nowrap">
|
||||
{formatDate(r.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleHide(r.id)}
|
||||
disabled={r.status === "deleted"}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-surface-container-high text-on-surface text-xs font-medium hover:bg-surface-container disabled:opacity-40"
|
||||
title={r.status === "hidden" ? "Unhide" : "Hide"}
|
||||
>
|
||||
{r.status === "hidden" ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||
{r.status === "hidden" ? "Unhide" : "Hide"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => softDelete(r.id)}
|
||||
disabled={r.status === "deleted"}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-error/15 text-error text-xs font-medium hover:bg-error/25 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && (
|
||||
<p className="p-8 text-center text-on-surface-variant">No board messages yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
frontend/app/board/layout.tsx
Normal file
16
frontend/app/board/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Message Board — Pay with Lightning",
|
||||
description:
|
||||
"Post a public message on the Belgian Bitcoin Embassy board. Pay a small Lightning invoice via LNbits to publish.",
|
||||
openGraph: {
|
||||
title: "Message Board — Belgian Bitcoin Embassy",
|
||||
description: "Pay with Lightning to post a message.",
|
||||
},
|
||||
alternates: { canonical: "/board" },
|
||||
};
|
||||
|
||||
export default function BoardLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
449
frontend/app/board/page.tsx
Normal file
449
frontend/app/board/page.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { shortenPubkey } from "@/lib/nostr";
|
||||
import { Copy, Check, Heart, Zap } from "lucide-react";
|
||||
|
||||
type BoardMessage = {
|
||||
id: string;
|
||||
paymentHash: string;
|
||||
content: string;
|
||||
authorName: string;
|
||||
pubkey: string | null;
|
||||
profilePic: string | null;
|
||||
satsPaid: number;
|
||||
likeCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PayPhase = "idle" | "polling" | "confirming" | "paid";
|
||||
|
||||
const MAX_LEN = 300;
|
||||
|
||||
function BoardAvatar({
|
||||
picture,
|
||||
name,
|
||||
size = 40,
|
||||
}: {
|
||||
picture?: string | null;
|
||||
name: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const [err, setErr] = useState(false);
|
||||
const initial = name.slice(0, 1).toUpperCase() || "?";
|
||||
if (picture && !err) {
|
||||
return (
|
||||
<Image
|
||||
src={picture}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="rounded-full object-cover shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
onError={() => setErr(true)}
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-sm shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BoardPage() {
|
||||
const { user } = useAuth();
|
||||
const [config, setConfig] = useState<{
|
||||
priceSats: number;
|
||||
bbeZapPubkey: string | null;
|
||||
bbeZapAddress: string | null;
|
||||
} | null>(null);
|
||||
const [messages, setMessages] = useState<BoardMessage[]>([]);
|
||||
const [content, setContent] = useState("");
|
||||
const [guestName, setGuestName] = useState("");
|
||||
const [postAsAnon, setPostAsAnon] = useState(false);
|
||||
const [payPhase, setPayPhase] = useState<PayPhase>("idle");
|
||||
const [payError, setPayError] = useState("");
|
||||
const [invoice, setInvoice] = useState<{ pr: string; hash: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [localLikes, setLocalLikes] = useState<Record<string, number>>({});
|
||||
const [highlightPaymentHash, setHighlightPaymentHash] = useState<string | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
const list = await api.getBoardMessages();
|
||||
setMessages(list as BoardMessage[]);
|
||||
}, []);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
const c = await api.getBoardConfig();
|
||||
setConfig(c);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().catch(() => {});
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMessages().catch(() => {});
|
||||
const t = setInterval(() => {
|
||||
loadMessages().catch(() => {});
|
||||
}, 12_000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const displayName = user?.name || user?.displayName || "";
|
||||
const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : "";
|
||||
const shortNpub = npub.length > 20 ? `${npub.slice(0, 14)}…${npub.slice(-12)}` : npub;
|
||||
|
||||
const handlePayAndPost = async () => {
|
||||
setPayError("");
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
setPayError("Write a message first.");
|
||||
return;
|
||||
}
|
||||
if (trimmed.length > MAX_LEN) {
|
||||
setPayError(`Message must be ${MAX_LEN} characters or fewer.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Parameters<typeof api.createBoardInvoice>[0] = { content: trimmed };
|
||||
if (user?.pubkey && !postAsAnon) {
|
||||
body.pubkey = user.pubkey;
|
||||
body.name = displayName || undefined;
|
||||
body.profilePic = user.picture;
|
||||
} else {
|
||||
const n = guestName.trim();
|
||||
if (n) body.name = n;
|
||||
}
|
||||
body.postAsAnon = !!(user?.pubkey && postAsAnon);
|
||||
|
||||
const inv = await api.createBoardInvoice(body);
|
||||
setHighlightPaymentHash(inv.payment_hash);
|
||||
setInvoice({ pr: inv.payment_request, hash: inv.payment_hash });
|
||||
setPayPhase("polling");
|
||||
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await api.getBoardPaymentStatus(inv.payment_hash);
|
||||
if (!status.paid) return;
|
||||
|
||||
// Payment detected — try to ensure the message row exists
|
||||
setPayPhase("confirming");
|
||||
setInvoice(null);
|
||||
|
||||
if (!status.messageCreated) {
|
||||
// Webhook may not have fired yet; call confirm to create the row
|
||||
await api.confirmBoardPayment(inv.payment_hash).catch(() => {});
|
||||
}
|
||||
|
||||
// Poll until the message actually appears in the list (max ~30s)
|
||||
let found = false;
|
||||
for (let attempt = 0; attempt < 15; attempt++) {
|
||||
const list = await api.getBoardMessages();
|
||||
setMessages(list as BoardMessage[]);
|
||||
if (list.some((m) => m.paymentHash === inv.payment_hash)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
// Try confirm again if first attempt didn't work
|
||||
if (attempt === 2) {
|
||||
await api.confirmBoardPayment(inv.payment_hash).catch(() => {});
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
|
||||
if (found) {
|
||||
setPayPhase("paid");
|
||||
setContent("");
|
||||
setGuestName("");
|
||||
setPostAsAnon(false);
|
||||
setTimeout(() => {
|
||||
setPayPhase("idle");
|
||||
setHighlightPaymentHash(null);
|
||||
}, 4000);
|
||||
} else {
|
||||
setPayPhase("idle");
|
||||
setPayError("Payment received but message is delayed — it will appear shortly.");
|
||||
}
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e: unknown) {
|
||||
setPayPhase("idle");
|
||||
setPayError(e instanceof Error ? e.message : "Could not create invoice.");
|
||||
}
|
||||
};
|
||||
|
||||
const copyPr = async () => {
|
||||
if (!invoice?.pr) return;
|
||||
await navigator.clipboard.writeText(invoice.pr);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const bumpLike = async (msg: BoardMessage) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const { likeCount } = await api.likeBoardMessage(msg.id);
|
||||
setLocalLikes((prev) => ({ ...prev, [msg.id]: likeCount }));
|
||||
} catch (e: unknown) {
|
||||
setPayError(e instanceof Error ? e.message : "Like failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleZap = async (msg: BoardMessage) => {
|
||||
const zapAddress = config?.bbeZapAddress;
|
||||
const fallbackPub = config?.bbeZapPubkey;
|
||||
|
||||
try {
|
||||
const w = window as unknown as {
|
||||
nostr?: { zap?: (args: Record<string, unknown>) => Promise<unknown> };
|
||||
};
|
||||
if (msg.pubkey && w.nostr?.zap) {
|
||||
await w.nostr.zap({
|
||||
pubkey: msg.pubkey,
|
||||
amount: 21,
|
||||
comment: "BBE board zap",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
if (msg.pubkey) {
|
||||
window.open(`https://njump.me/${nip19.npubEncode(msg.pubkey)}`, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
if (zapAddress) {
|
||||
const addr = zapAddress.replace(/^lightning:/i, "");
|
||||
window.location.href = `lightning:${addr}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackPub) {
|
||||
try {
|
||||
window.open(
|
||||
`https://njump.me/${nip19.npubEncode(fallbackPub)}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
} catch {
|
||||
setPayError("Invalid BOARD_ZAP_PUBKEY on server (expected hex pubkey).");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setPayError("Zap: configure BOARD_ZAP_LN_ADDRESS or BOARD_ZAP_PUBKEY on the server.");
|
||||
};
|
||||
|
||||
const len = content.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="min-h-screen pb-20">
|
||||
<div className="max-w-3xl mx-auto px-8 pt-16 pb-10">
|
||||
<h1 className="text-4xl font-black mb-2">Message board</h1>
|
||||
<p className="text-on-surface-variant text-lg mb-10">
|
||||
Pay {config?.priceSats ?? "…"} sats via Lightning to post. Messages are public and moderated.
|
||||
</p>
|
||||
|
||||
<section className="rounded-2xl border border-outline-variant/30 bg-surface-container-low p-6 mb-12">
|
||||
<h2 className="text-lg font-bold text-on-surface mb-4">Post a message</h2>
|
||||
|
||||
{user && !postAsAnon ? (
|
||||
<div className="flex items-center gap-3 mb-4 p-3 rounded-xl bg-surface-container">
|
||||
<BoardAvatar picture={user.picture} name={displayName || "You"} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-on-surface truncate">{displayName || "Nostr user"}</p>
|
||||
<p className="text-xs font-mono text-on-surface-variant truncate" title={npub}>
|
||||
{shortNpub}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 mb-4">
|
||||
{user && (
|
||||
<label className="flex items-center gap-2 text-sm text-on-surface-variant cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={postAsAnon}
|
||||
onChange={(e) => setPostAsAnon(e.target.checked)}
|
||||
className="rounded border-outline-variant"
|
||||
/>
|
||||
Post as anon (hide Nostr profile)
|
||||
</label>
|
||||
)}
|
||||
{!user || postAsAnon ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1">
|
||||
Name (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
placeholder="Guest"
|
||||
maxLength={80}
|
||||
className="w-full rounded-lg border border-outline-variant bg-surface px-3 py-2 text-on-surface"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value.slice(0, MAX_LEN))}
|
||||
placeholder="What’s on your mind?"
|
||||
rows={4}
|
||||
maxLength={MAX_LEN}
|
||||
className="w-full rounded-xl border border-outline-variant bg-surface px-4 py-3 text-on-surface placeholder:text-on-surface-variant/50 resize-y min-h-[120px]"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2 text-sm text-on-surface-variant">
|
||||
<span>
|
||||
{len}/{MAX_LEN}
|
||||
</span>
|
||||
{payPhase === "polling" && (
|
||||
<span className="text-primary font-medium animate-pulse">Awaiting payment…</span>
|
||||
)}
|
||||
{payPhase === "confirming" && (
|
||||
<span className="text-primary font-medium animate-pulse">Payment received — publishing…</span>
|
||||
)}
|
||||
{payPhase === "paid" && <span className="text-green-500 font-medium">Posted!</span>}
|
||||
</div>
|
||||
|
||||
{payError && <p className="text-error text-sm mt-3">{payError}</p>}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handlePayAndPost}
|
||||
disabled={payPhase === "polling" || payPhase === "confirming" || !content.trim()}
|
||||
>
|
||||
Pay & post
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invoice && payPhase === "polling" && (
|
||||
<div className="mt-8 p-4 rounded-xl bg-surface-container border border-outline-variant/40">
|
||||
<p className="text-sm text-on-surface-variant mb-3">Scan or copy the Lightning invoice:</p>
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-start">
|
||||
<div className="bg-white p-3 rounded-lg shrink-0">
|
||||
<QRCodeSVG value={invoice.pr} size={180} level="M" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPr}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copied ? "Copied" : "Copy invoice"}
|
||||
</button>
|
||||
<p className="text-xs font-mono break-all text-on-surface-variant max-h-32 overflow-y-auto">
|
||||
{invoice.pr}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-bold mb-4">Messages</h2>
|
||||
<ul className="space-y-4">
|
||||
{messages.map((m) => {
|
||||
const likes = localLikes[m.id] ?? m.likeCount;
|
||||
const isNew = highlightPaymentHash && m.paymentHash === highlightPaymentHash;
|
||||
return (
|
||||
<li
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"rounded-2xl border p-5 transition-shadow duration-500",
|
||||
isNew
|
||||
? "border-primary bg-primary-container/10 shadow-lg shadow-primary/20"
|
||||
: "border-outline-variant/30 bg-surface-container-low"
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<BoardAvatar picture={m.profilePic} name={m.authorName} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-2 mb-1">
|
||||
<span className="font-bold text-on-surface">{m.authorName}</span>
|
||||
<span className="text-xs text-on-surface-variant">{formatDate(m.createdAt)}</span>
|
||||
<span className="text-xs font-mono text-primary">⚡ {m.satsPaid} sats</span>
|
||||
</div>
|
||||
<p className="text-on-surface whitespace-pre-wrap break-words">{m.content}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{m.pubkey && user ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bumpLike(m)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface hover:bg-surface-container transition-colors"
|
||||
>
|
||||
<Heart size={16} className="text-red-400" />
|
||||
{likes}
|
||||
</button>
|
||||
) : m.pubkey ? (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface-variant">
|
||||
<Heart size={16} className="text-red-400/70" />
|
||||
{likes}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleZap(m)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface hover:bg-surface-container transition-colors"
|
||||
>
|
||||
<Zap size={16} className="text-amber-400" />
|
||||
Zap
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{messages.length === 0 && (
|
||||
<p className="text-on-surface-variant text-center py-12">No messages yet — be the first.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Inbox,
|
||||
ImageIcon,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
@@ -28,6 +29,7 @@ const navItems = [
|
||||
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
|
||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
|
||||
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
|
||||
{ href: "/admin/messages", label: "Board", icon: MessageSquare, adminOnly: false },
|
||||
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
|
||||
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
|
||||
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
|
||||
|
||||
@@ -14,6 +14,7 @@ const SECTION_LINKS = [{ label: "About", anchor: "about" }];
|
||||
|
||||
const PAGE_LINKS = [
|
||||
{ label: "Meetups", href: "/events" },
|
||||
{ label: "Board", href: "/board" },
|
||||
{ label: "Community", href: "/community" },
|
||||
{ label: "FAQ", href: "/faq" },
|
||||
];
|
||||
|
||||
@@ -183,4 +183,51 @@ export const api = {
|
||||
},
|
||||
reviewSubmission: (id: string, data: { status: string; reviewNote?: string }) =>
|
||||
request<any>(`/submissions/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
|
||||
// Message board (Lightning)
|
||||
getBoardConfig: () =>
|
||||
request<{ priceSats: number; bbeZapPubkey: string | null; bbeZapAddress: string | null }>(
|
||||
"/messages/config"
|
||||
),
|
||||
createBoardInvoice: (data: {
|
||||
content: string;
|
||||
name?: string;
|
||||
pubkey?: string;
|
||||
profilePic?: string;
|
||||
postAsAnon?: boolean;
|
||||
}) =>
|
||||
request<{ payment_request: string; checking_id: string; payment_hash: string }>(
|
||||
"/messages/invoice",
|
||||
{ method: "POST", body: JSON.stringify(data) }
|
||||
),
|
||||
getBoardPaymentStatus: (paymentHash: string) =>
|
||||
request<{ paid: boolean; messageCreated: boolean }>(
|
||||
`/messages/payment/${encodeURIComponent(paymentHash)}/status`
|
||||
),
|
||||
confirmBoardPayment: (paymentHash: string) =>
|
||||
request<{ ok: boolean; duplicate?: boolean; ignored?: string }>(
|
||||
`/messages/confirm/${encodeURIComponent(paymentHash)}`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
getBoardMessages: () =>
|
||||
request<
|
||||
Array<{
|
||||
id: string;
|
||||
paymentHash: string;
|
||||
content: string;
|
||||
authorName: string;
|
||||
pubkey: string | null;
|
||||
profilePic: string | null;
|
||||
satsPaid: number;
|
||||
likeCount: number;
|
||||
createdAt: string;
|
||||
}>
|
||||
>("/messages"),
|
||||
likeBoardMessage: (id: string) =>
|
||||
request<{ likeCount: number }>(`/messages/${id}/like`, { method: "POST" }),
|
||||
getAdminBoardMessages: () => request<any[]>("/admin/messages"),
|
||||
hideBoardMessage: (id: string) =>
|
||||
request<any>(`/admin/messages/${id}/hide`, { method: "POST" }),
|
||||
deleteBoardMessage: (id: string) =>
|
||||
request<any>(`/admin/messages/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user