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:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user