Files
buzz/app/(ui)/donate/admin/client.tsx
T
dmgnr 2e0d7042d7
Build and Push Image / build-backend (push) Successful in 24s
Build and Push Image / build-frontend (push) Successful in 1m48s
Build and Push Image / push (push) Successful in 42s
feat(don): right click show hover card instead
2026-09-13 21:46:33 +07:00

399 lines
11 KiB
TypeScript

"use client";
import type { ColumnDef } from "@tanstack/react-table";
import {
BitcoinIcon,
BugPlay,
ChevronsLeftRightEllipsis,
CoinsIcon,
Copy,
Goal,
ImageIcon,
MessageCircleWarning,
WalletIcon,
QrCodeIcon as PromptpayIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { type ComponentProps, useEffect, useRef, useState } from "react";
import { DataTable } from "@/components/tantable";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import type { donations } from "@/lib/db/schema";
import { sse } from "@/lib/db/sse-endpoints";
import { ActionButton } from "../../../../components/action-button";
import { getImage, reloadWidget, resendPopup, testPopup } from "./api";
import { formatDistanceToNow } from "date-fns";
import { lePalette } from "../../rubgram/admin/[id]/client";
import { th } from "date-fns/locale";
import TruemoneyIcon from "#/assets/tmn.webp";
import Image from "@/components/image";
import { Checkbox } from "@/components/ui/checkbox";
import { toggleCheck } from "@/lib/api";
// directly adapted for date instead of UUID
function colorFor(date: Date) {
const seed =
date.getUTCFullYear() * 10000 +
(date.getUTCMonth() + 1) * 100 +
date.getUTCDate();
return lePalette[seed % lePalette.length];
}
function ArtifactCheckbox({
checked,
submissionId,
}: {
checked: boolean;
submissionId: string;
}) {
const [value, setValue] = useState(checked);
useEffect(() => {
setValue(checked);
}, [checked]);
return (
<Checkbox
checked={value}
onCheckedChange={async (checked) => {
setValue(checked === true);
await toggleCheck(submissionId);
}}
/>
);
}
const columns: ColumnDef<
typeof donations.$inferSelect & {
checked: boolean | null;
artifactSubmissionId: string | null;
}
>[] = [
{
accessorKey: "created",
header: "",
cell(row) {
return (
<div
className="absolute inset-0"
style={{
background: colorFor(row.row.original.created).bg,
}}
></div>
);
},
meta: { className: "w-1 p-0 relative" },
},
{ accessorKey: "name", header: "ชื่อ", meta: { className: "w-50 truncate" } },
{
accessorKey: "art_check",
header: "",
meta: { className: "w-6 truncate" },
cell(row) {
const { artifactSubmissionId, checked } = row.row.original;
if (checked === null || !artifactSubmissionId) return;
return (
<ArtifactCheckbox
checked={checked}
submissionId={artifactSubmissionId}
/>
);
},
},
{
accessorFn: (row) => `${row.amount}฿`,
header: "จำนวน",
meta: { className: "w-24" },
},
{
accessorKey: "message",
cell(props) {
const [open, setOpen] = useState(false);
return (
<HoverCard
openDelay={150}
closeDelay={200}
open={open}
onOpenChange={(o) => o || setOpen(false)}
>
<HoverCardTrigger>
<div
className="truncate"
onContextMenu={(ev) => {
ev.preventDefault();
setOpen(true);
}}
>
{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>
);
},
header: "ข้อความ",
meta: { className: "w-full" },
},
{
accessorKey: "id",
header() {
return (
<div className="flex gap-1">
<SimpleTooltip text="Send test popup">
<ActionButton
variant="outline"
size="icon-sm"
action={() => {
posthog.capture("donation_admin_test_popup");
return testPopup();
}}
>
<BugPlay />
</ActionButton>
</SimpleTooltip>
<SimpleTooltip text="Reload all widget">
<ActionButton
variant="outline"
size="icon-sm"
action={() => {
posthog.capture("donation_admin_widget_reload");
return reloadWidget();
}}
>
<ChevronsLeftRightEllipsis />
</ActionButton>
</SimpleTooltip>
<SimpleTooltip text="Set Goal">
<Button asChild variant="outline" size="icon-sm">
<Link href="/donate/admin/goal">
<Goal />
</Link>
</Button>
</SimpleTooltip>
</div>
);
},
cell(row) {
return <ActionRow row={row.row.original} filler />;
},
meta: { className: "w-36 p-0" },
},
];
function ActionRow({
row,
filler,
}: {
row: typeof donations.$inferSelect;
filler?: boolean;
}) {
return (
<div className="flex gap-1">
{row.method === "pp" ? (
<PromptpayIcon className="size-8 p-1 opacity-50" />
) : (
<Image
src={TruemoneyIcon}
alt="truemoney"
className="size-8 p-1 opacity-50 brightness-200 grayscale"
/>
)}
<SimpleTooltip text="แสดง Popup อีกครั้ง">
<ActionButton
variant="outline"
size="icon-sm"
action={() => {
posthog.capture("donation_admin_resend", {
id: row.id,
});
return resendPopup(row.id);
}}
>
<MessageCircleWarning />
</ActionButton>
</SimpleTooltip>
{row.uid ? (
<SimpleTooltip text="ก็อบ UID">
<ActionButton
size="icon-sm"
action={() => navigator.clipboard.writeText(row.uid!)}
>
<Copy />
</ActionButton>
</SimpleTooltip>
) : filler ? (
<div className="size-8" />
) : undefined}
{row.image && (
<SimpleTooltip text="เปิดภาพ">
<ActionButton
size="icon-sm"
variant="outline"
action={async () => {
await getImage(row.id).then((blob) => {
const url = URL.createObjectURL(blob);
const tab = window.open("about:blank", "_blank");
if (tab) {
tab.location.href = url;
}
});
}}
>
<ImageIcon />
</ActionButton>
</SimpleTooltip>
)}
</div>
);
}
function TopRow({
row,
stats,
...props
}: {
row?: typeof donations.$inferSelect;
stats: {
total: number;
today: number;
};
} & ComponentProps<"div">) {
const [time, setTime] = useState("");
useEffect(() => {
if (!row) return setTime("");
function update() {
setTime(`${formatDistanceToNow(row!.created, { locale: th })}ที่แล้ว`);
}
const interval = setInterval(update, 1000);
update();
return () => clearInterval(interval);
}, [row]);
return (
<div className="flex w-full gap-2 p-1" {...props}>
{row ? (
<div className="flex flex-10 flex-col rounded-lg border bg-card/50 py-1 pr-1 pl-2">
<div className="flex justify-between text-xl">
<div className="flex gap-2">
<span className="font-semibold">{row.name}</span>
<span className="opacity-70">{row.amount}฿</span>
</div>
<span className="pr-1 opacity-70">{time}</span>
</div>
<div className="min-h-20">{row.message}</div>
<div className="flex justify-end">
<ActionRow row={row} />
</div>
</div>
) : (
<div className="flex-10 items-center justify-center rounded-lg border bg-card/50 px-2 py-1">
<span>No donation yet</span>
</div>
)}
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border bg-card/50 px-2 py-1">
<CoinsIcon className="absolute top-1/3 left-1/3 size-6/8 opacity-20" />
<span>รวมวันนี้</span>
<span className="text-3xl font-bold">{stats.today}฿</span>
</div>
<Link
className="relative flex flex-1 flex-col overflow-hidden rounded-lg border bg-card/50 px-2 py-1"
href="/donate/admin/calendar"
>
<WalletIcon className="absolute top-1/3 left-1/3 size-6/8 opacity-20" />
<span>รวมเดือนนี้</span>
<span className="text-3xl font-bold">{stats.total}฿</span>
</Link>
</div>
);
}
export function DonateAdminPage({
data,
stats,
}: {
data: (typeof donations.$inferSelect & {
checked: boolean | null;
artifactSubmissionId: string | null;
})[];
stats: {
total: number;
today: number;
};
}) {
const [maxHeight, setMaxHeight] = useState("");
const topRowRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setMaxHeight(`calc(100vh - 93px - ${topRowRef.current?.clientHeight}px)`);
}, [data]);
return (
<div className="mx-auto flex w-full max-w-[max(1280px,90%)] flex-col">
<span className="flex items-center gap-1 pt-1 pb-2 text-3xl font-semibold">
<BitcoinIcon size={32} />
โดเนทเดือนนี้
</span>
<TopRow row={data[0]} ref={topRowRef} stats={stats} />
<DataTable
columns={columns}
emptyDescription="No donation came in yet."
data={data}
className="w-full overflow-y-auto bg-black/25 backdrop-blur-sm"
style={{
maxHeight,
}}
></DataTable>
<DonateWatcher />
</div>
);
}
// component cuz simplicity sake
export function DonateWatcher() {
const router = useRouter();
const [newDonates, setNewDonates] = useState(0);
useEffect(() => {
if (newDonates <= 0) return ((document.title = "โดเนททั้งหมด"), undefined);
var blink = true;
const interval = setInterval(() => {
blink = !blink;
document.title = blink ? "โดเนททั้งหมด" : `${newDonates} โดเนทใหม่`;
}, 1000);
return () => clearInterval(interval);
}, [newDonates]);
useEffect(() => {
function listener() {
setNewDonates(0);
}
window.addEventListener("focus", listener);
return () => window.removeEventListener("focus", listener);
}, []);
useEffect(() => {
if (!router) return;
return sse.donate.subMany({
ping: () => (router.refresh(), setNewDonates((x) => x + 1)),
update: router.refresh.bind(router),
}).clean;
}, [router]);
return undefined;
}