feat(don): add public top donation page
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { asc, desc, sql } from "drizzle-orm";
|
||||
import { Suspense } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { db } from "@/lib/db";
|
||||
import { donations } from "@/lib/db/schema";
|
||||
import { fileToDataUrl } from "@/lib/utils";
|
||||
import { Podium } from "./podium";
|
||||
import { TopDonateTable } from "./table";
|
||||
|
||||
type NumberString = `${number}`;
|
||||
type NumberLike = number | NumberString;
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Content />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div className="min-h-svh flex flex-col justify-center">
|
||||
<div className="max-w-4xl w-full mx-auto flex flex-col gap-4 p-4">
|
||||
<div className="flex justify-center items-end gap-4 mt-6">
|
||||
<Skeleton className="h-36 w-36 rounded-xl" />
|
||||
<Skeleton className="h-48 w-36 rounded-xl" />
|
||||
<Skeleton className="h-32 w-36 rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function Content() {
|
||||
const list = await db
|
||||
.select({
|
||||
i: sql<NumberLike>`ROW_NUMBER() OVER
|
||||
(ORDER BY ${desc(donations.amount)}, ${asc(donations.id)})`,
|
||||
name: donations.name,
|
||||
amount: donations.amount,
|
||||
image: donations.image,
|
||||
})
|
||||
.from(donations)
|
||||
.offset(3)
|
||||
.limit(7)
|
||||
.orderBy(desc(donations.amount), asc(donations.id));
|
||||
|
||||
const podium = await db
|
||||
.select({
|
||||
name: donations.name,
|
||||
amount: donations.amount,
|
||||
image: donations.image,
|
||||
})
|
||||
.from(donations)
|
||||
.limit(3)
|
||||
.orderBy(desc(donations.amount), asc(donations.id))
|
||||
.then((r) =>
|
||||
Promise.all(
|
||||
r.map(async (d) => ({
|
||||
...d,
|
||||
image: d.image
|
||||
? await fileToDataUrl(new File([Buffer.from(d.image)], "image.jpg"))
|
||||
: undefined,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh flex flex-col justify-center">
|
||||
<div className="max-w-4xl mx-auto flex flex-col gap-4 p-4">
|
||||
<Podium data={podium} />
|
||||
<TopDonateTable data={list} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { Award, Trophy } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
const styles = [
|
||||
{
|
||||
border: "border-yellow-500/50",
|
||||
bg: "bg-yellow-500/5",
|
||||
height: "h-48",
|
||||
text: "text-yellow-600",
|
||||
},
|
||||
{
|
||||
border: "border-zinc-400/50",
|
||||
bg: "bg-zinc-400/5",
|
||||
height: "h-36",
|
||||
text: "text-zinc-500",
|
||||
},
|
||||
{
|
||||
border: "border-amber-700/50",
|
||||
bg: "bg-amber-700/5",
|
||||
height: "h-32",
|
||||
text: "text-amber-700",
|
||||
},
|
||||
];
|
||||
|
||||
interface TopDonateRow {
|
||||
name: string;
|
||||
amount: number;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export function Podium({ data }: { data: TopDonateRow[] }) {
|
||||
// [1,2,3] -> [2,1,3]
|
||||
const order = [data[1], data[0], data[2]].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-end gap-4 mt-6">
|
||||
{order.map((donor, index) => {
|
||||
const rank = index === 0 ? 1 : index === 1 ? 0 : 2;
|
||||
const s = styles[rank];
|
||||
const initial = donor.name?.charAt(0).toUpperCase() ?? "?";
|
||||
|
||||
return (
|
||||
<div key={rank} className="relative flex flex-col items-center">
|
||||
<div className="absolute -top-8 z-10">
|
||||
{rank === 0 ? (
|
||||
<Trophy size={28} className="text-yellow-500" />
|
||||
) : (
|
||||
<Award size={28} className={s.text} />
|
||||
)}
|
||||
</div>
|
||||
<Card
|
||||
className={`${s.border} ${s.bg} ${s.height} w-36 flex flex-col items-center justify-end pb-0 backdrop-blur-md`}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center gap-2 p-4">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={donor.image} />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-semibold text-sm truncate w-full text-center">
|
||||
{donor.name}
|
||||
</span>
|
||||
<span className={`text-lg font-bold ${s.text}`}>
|
||||
{donor.amount}฿
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@/components/tantable";
|
||||
|
||||
type NumberString = `${number}`;
|
||||
type NumberLike = number | NumberString;
|
||||
|
||||
interface TopDonateRow {
|
||||
i: NumberLike;
|
||||
name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TopDonateRow>[] = [
|
||||
{
|
||||
accessorKey: "i",
|
||||
header: "#",
|
||||
meta: { className: "w-12 text-center" },
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
meta: { className: "w-full truncate" },
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => `${row.amount}฿`,
|
||||
header: "Amount",
|
||||
meta: { className: "w-24" },
|
||||
},
|
||||
];
|
||||
|
||||
export function TopDonateTable({ data }: { data: TopDonateRow[] }) {
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
emptyDescription="No donations yet."
|
||||
className="w-full h-full max-h-[calc(100svh-264px)] overflow-y-auto backdrop-blur-md"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user