feat(don): admin overhaul
Build and Push Image / build-backend (push) Successful in 4m30s
Build and Push Image / build-frontend (push) Successful in 5m36s
Build and Push Image / push (push) Successful in 1m18s

This commit is contained in:
2026-07-13 00:40:59 +07:00 Unverified
parent 795cda1db3
commit f1622792c7
4 changed files with 105 additions and 22 deletions
+71 -9
View File
@@ -5,15 +5,17 @@ import {
BitcoinIcon, BitcoinIcon,
BugPlay, BugPlay,
ChevronsLeftRightEllipsis, ChevronsLeftRightEllipsis,
CoinsIcon,
Copy, Copy,
Goal, Goal,
ImageIcon, ImageIcon,
MessageCircleWarning, MessageCircleWarning,
WalletIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import posthog from "posthog-js"; import posthog from "posthog-js";
import { useEffect, useState } from "react"; import { type ComponentProps, useEffect, useRef, useState } from "react";
import { useEllipsisVisible } from "react-hook-text-overflow"; import { useEllipsisVisible } from "react-hook-text-overflow";
import { DataTable } from "@/components/tantable"; import { DataTable } from "@/components/tantable";
import { SimpleTooltip } from "@/components/tooltip"; import { SimpleTooltip } from "@/components/tooltip";
@@ -28,8 +30,35 @@ import { sse } from "@/lib/db/sse-endpoints";
import { ActionButton } from "../../../../components/action-button"; import { ActionButton } from "../../../../components/action-button";
import { getImage, reloadWidget, resendPopup, testPopup } from "./api"; import { getImage, reloadWidget, resendPopup, testPopup } from "./api";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { lePalette } from "../../rubgram/admin/[id]/client";
import { th } from "date-fns/locale";
// 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];
}
const columns: ColumnDef<typeof donations.$inferSelect>[] = [ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
{
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: "name", header: "ชื่อ", meta: { className: "w-50 truncate" } },
{ {
accessorFn: (row) => `${row.amount}฿`, accessorFn: (row) => `${row.amount}฿`,
@@ -169,20 +198,30 @@ function ActionRow({
); );
} }
function TopRow({ row }: { row?: typeof donations.$inferSelect }) { function TopRow({
row,
stats,
...props
}: {
row?: typeof donations.$inferSelect;
stats: {
total: number;
today: number;
};
} & ComponentProps<"div">) {
const [time, setTime] = useState(""); const [time, setTime] = useState("");
useEffect(() => { useEffect(() => {
if (!row) return setTime(""); if (!row) return setTime("");
function update() { function update() {
setTime(`${formatDistanceToNow(row!.created)} ago`); setTime(`${formatDistanceToNow(row!.created, { locale: th })}ที่แล้ว`);
} }
const interval = setInterval(update, 1000); const interval = setInterval(update, 1000);
update(); update();
return () => clearTimeout(interval); return () => clearInterval(interval);
}, [row]); }, [row]);
return ( return (
<div className="flex w-full gap-2 p-1"> <div className="flex w-full gap-2 p-1" {...props}>
{row ? ( {row ? (
<div className="flex flex-10 flex-col rounded-lg border bg-card/50 py-1 pr-1 pl-2"> <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 justify-between text-xl">
@@ -202,29 +241,52 @@ function TopRow({ row }: { row?: typeof donations.$inferSelect }) {
<span>No donation yet</span> <span>No donation yet</span>
</div> </div>
)} )}
{/* <div className="flex-1 rounded-lg border bg-card/50 px-2 py-1">Hi</div> <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border bg-card/50 px-2 py-1">
<div className="flex-1 rounded-lg border bg-card/50 px-2 py-1">Hi</div> */} <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>
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border bg-card/50 px-2 py-1 ">
<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>
</div>
</div> </div>
); );
} }
export function DonateAdminPage({ export function DonateAdminPage({
data, data,
stats,
}: { }: {
data: (typeof donations.$inferSelect)[]; data: (typeof donations.$inferSelect)[];
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 ( return (
<div className="mx-auto flex w-full max-w-[max(1280px,90%)] flex-col"> <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"> <span className="flex items-center gap-1 pt-1 pb-2 text-3xl font-semibold">
<BitcoinIcon size={32} /> <BitcoinIcon size={32} />
โดเนททั้งหมด โดเนททั้งหมด
</span> </span>
<TopRow row={data[0]} /> <TopRow row={data[0]} ref={topRowRef} stats={stats} />
<DataTable <DataTable
columns={columns} columns={columns}
emptyDescription="No donation came in yet." emptyDescription="No donation came in yet."
data={data.slice(1)} data={data.slice(1)}
className="max-h-[calc(100vh-93px)] w-full overflow-y-auto bg-black/25 backdrop-blur-sm" className="w-full overflow-y-auto bg-black/25 backdrop-blur-sm"
style={{
maxHeight,
}}
></DataTable> ></DataTable>
<DonateWatcher /> <DonateWatcher />
</div> </div>
+26 -10
View File
@@ -1,4 +1,4 @@
import { desc, getTableColumns, sql } from "drizzle-orm"; import { desc, getTableColumns, sql, sum } from "drizzle-orm";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { adminCheck } from "@/lib/auth"; import { adminCheck } from "@/lib/auth";
@@ -10,15 +10,31 @@ export default async function () {
if (!(await adminCheck())) if (!(await adminCheck()))
redirect(`/login?next=${encodeURIComponent("/donate/admin")}`); redirect(`/login?next=${encodeURIComponent("/donate/admin")}`);
const data = await db const [data, [stats]] = await Promise.all([
.select({ db
...getTableColumns(donations), .select({
image: sql<Buffer>`${donations.image} IS NOT NULL`, ...getTableColumns(donations),
}) image: sql<Buffer>`${donations.image} IS NOT NULL`,
.from(donations) })
.limit(100) .from(donations)
.orderBy(desc(donations.id)); .limit(100)
return <DonateAdminPage data={data} />; .orderBy(desc(donations.id)),
db
.select({
total: sum(donations.amount).mapWith(Number),
today: sql<number>`
COALESCE(
SUM(${donations.amount}) FILTER (
WHERE ${donations.created} >= NOW() - INTERVAL '24 hours'
),
0
)
`,
})
.from(donations)
.limit(1),
]);
return <DonateAdminPage data={data} stats={stats} />;
} }
export const metadata: Metadata = { export const metadata: Metadata = {
+1 -1
View File
@@ -23,7 +23,7 @@ import type { Note } from "@/lib/db/schema";
import { addNote, debugUploadSlip, deleteNote, discordCall } from "../../api"; import { addNote, debugUploadSlip, deleteNote, discordCall } from "../../api";
// by chatgpt // by chatgpt
const lePalette = [ export const lePalette = [
{ {
bg: "#A34A5B", bg: "#A34A5B",
border: "#C26779", border: "#C26779",
+7 -2
View File
@@ -9,6 +9,7 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { FolderCode, Plus } from "lucide-react"; import { FolderCode, Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import type { ComponentProps } from "react";
import { type MouseEventHandler, type ReactNode, useState } from "react"; import { type MouseEventHandler, type ReactNode, useState } from "react";
import { import {
Table, Table,
@@ -51,6 +52,7 @@ export function DataTable<TData, TValue>({
home, home,
context, context,
onClick, onClick,
style,
}: DataTableProps<TData, TValue> & { }: DataTableProps<TData, TValue> & {
className?: string; className?: string;
openCreateDialog?: () => void; openCreateDialog?: () => void;
@@ -66,7 +68,7 @@ export function DataTable<TData, TValue>({
icon?: ReactNode; icon?: ReactNode;
}[]; }[];
onClick?: (row: TData) => void; onClick?: (row: TData) => void;
}) { } & Pick<ComponentProps<"div">, "style">) {
const [internalRowSelection, setInternalRowSelection] = useState({}); const [internalRowSelection, setInternalRowSelection] = useState({});
const table = useReactTable({ const table = useReactTable({
@@ -80,7 +82,10 @@ export function DataTable<TData, TValue>({
}); });
return ( return (
<div className={cn("overflow-hidden rounded-md border", className)}> <div
className={cn("overflow-hidden rounded-md border", className)}
style={style}
>
<Table className="table-fixed"> <Table className="table-fixed">
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (