Files
BelgianBitcoinEmbassy/frontend/app/admin/messages/page.tsx
bbe 586b572f73 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
2026-04-03 18:37:52 +02:00

147 lines
5.3 KiB
TypeScript

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