feat(don): admin overhaul
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user