feat: add donate admin panel

DataTable for viewing donations with resend popup, test popup,
and SSE subscription for real-time refresh.
This commit is contained in:
2026-06-07 23:04:58 +00:00 Unverified
parent b594685901
commit add3e35be7
3 changed files with 183 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
"use server";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { donations } from "@/lib/db/schema";
import { sse } from "@/lib/db/sse-endpoints";
import { fileToDataUrl } from "@/lib/utils";
import { eq } from "drizzle-orm";
export async function testPopup() {
if (!(await adminCheck())) throw new Error("Unauthorized");
sse.donate.pub("ping", {
id: "test",
name: "Mr. Buzz",
message: "นี่คือข้อความทดสอบโดเนท",
amount: 67,
});
}
export async function resendPopup(id: string) {
if (!(await adminCheck())) throw new Error("Unauthorized");
const [sub] = await db
.update(donations)
.set({ sent: false })
.where(eq(donations.id, id))
.returning();
if (!sub) throw new Error("not found");
sse.donate.pub("ping", {
...sub,
message: sub.message ?? "",
image: sub.image
? await fileToDataUrl(new File([Buffer.from(sub.image)], "abc.jpeg"))
: undefined,
});
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { DataTable } from "@/components/tantable";
import { SimpleTooltip } from "@/components/tooltip";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { donations } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
import type { ColumnDef } from "@tanstack/react-table";
import { BitcoinIcon, BugPlay, Copy, MessageCircleWarning } from "lucide-react";
import { useEllipsisVisible } from "react-hook-text-overflow";
import { ActionButton } from "../../../../components/action-button";
import { resendPopup, testPopup } from "./api";
import { useEffect } from "react";
import { sse } from "@/lib/db/sse-endpoints";
import { useRouter } from "next/navigation";
const columns: ColumnDef<typeof donations.$inferSelect>[] = [
{ accessorKey: "name", header: "Name", meta: { className: "w-50 truncate" } },
{
accessorKey: "message",
cell(props) {
const [overflow, ref] = useEllipsisVisible();
return overflow ? (
<HoverCard openDelay={150} closeDelay={0}>
<HoverCardTrigger>
<div ref={ref} className="truncate">
{props.row.original.message}
</div>
</HoverCardTrigger>
<HoverCardContent className="flex w-5xl flex-col gap-0.5">
<div className="font-semibold">{props.row.original.name}</div>
<div>{props.row.original.message}</div>
<div className="mt-1 text-xs text-muted-foreground">
{`${props.row.original.amount}฿${props.row.original.uid ? ` - UID ${props.row.original.uid}` : ""}`}
</div>
</HoverCardContent>
</HoverCard>
) : (
<div ref={ref} className="truncate">
{props.row.original.message}
</div>
);
},
header: "Message",
meta: { className: "w-full" },
},
{
accessorFn: (row) => `${row.amount}฿`,
header: "Amount",
meta: { className: "w-24" },
},
{
accessorKey: "id",
header() {
return (
<SimpleTooltip text="Send test popup">
<ActionButton variant="outline" size="icon-sm" action={testPopup}>
<BugPlay />
</ActionButton>
</SimpleTooltip>
);
},
cell(row) {
return (
<div
className={cn(
"flex gap-1",
row.row.original.uid ||
"opacity-0 group-hover:opacity-100 transition-opacity",
)}
>
<SimpleTooltip text="Re-send Popup">
<ActionButton
variant="outline"
size="icon-sm"
action={() => resendPopup(row.row.original.id)}
>
<MessageCircleWarning />
</ActionButton>
</SimpleTooltip>
{row.row.original.uid && (
<SimpleTooltip text="Copy Artifact UID">
<ActionButton
size="icon-sm"
action={() =>
navigator.clipboard.writeText(row.row.original.uid!)
}
>
<Copy />
</ActionButton>
</SimpleTooltip>
)}
</div>
);
},
meta: { className: "w-24 p-0" },
},
];
export function DonateAdminPage({
data,
}: {
data: (typeof donations.$inferSelect)[];
}) {
const router = useRouter();
useEffect(() => {
if (!router) return;
return sse.donate.sub("ping", () => router.refresh()).clean;
}, [router]);
return (
<div className="mx-auto w-full max-w-[max(1280px,90%)] flex flex-col">
<span className="flex items-center text-3xl font-semibold gap-1 pt-1 pb-2">
<BitcoinIcon size={32} />
Donate List
</span>
<DataTable
columns={columns}
emptyDescription="No donation came in yet."
data={data}
className="w-full max-h-[calc(100vh-93px)] overflow-y-auto backdrop-blur-sm bg-black/25"
></DataTable>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { db } from "@/lib/db";
import { DonateAdminPage } from "./client";
import { donations } from "@/lib/db/schema";
import { desc } from "drizzle-orm";
import { adminCheck } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function () {
if (!(await adminCheck())) redirect("/login");
const data = await db
.select()
.from(donations)
.limit(100)
.orderBy(desc(donations.id));
return <DonateAdminPage data={data} />;
}