feat(don): add moderator page
Build and Push Image / build-backend (push) Successful in 46m52s
Build and Push Image / build-frontend (push) Successful in 49m25s
Build and Push Image / push (push) Successful in 5m14s

This commit is contained in:
2026-06-27 17:08:23 +07:00 Unverified
parent a79fc15637
commit 51413c2c88
8 changed files with 174 additions and 11 deletions
+24 -10
View File
@@ -13,7 +13,7 @@ import {
import Link from "next/link";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useEllipsisVisible } from "react-hook-text-overflow";
import { DataTable } from "@/components/tantable";
import { SimpleTooltip } from "@/components/tooltip";
@@ -165,15 +165,6 @@ export function DonateAdminPage({
}: {
data: (typeof donations.$inferSelect)[];
}) {
const router = useRouter();
useEffect(() => {
if (!router) return;
return sse.donate.subMany({
ping: () => router.refresh(),
update: () => router.refresh(),
}).clean;
}, [router]);
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">
@@ -186,6 +177,29 @@ export function DonateAdminPage({
data={data}
className="max-h-[calc(100vh-93px)] w-full overflow-y-auto bg-black/25 backdrop-blur-sm"
></DataTable>
<DonateWatcher />
</div>
);
}
// component cuz simplicity sake
export function DonateWatcher({ sfx = false }: { sfx?: boolean }) {
const player = useRef<HTMLAudioElement | null>(null);
const router = useRouter();
useEffect(() => {
if (!router) return;
return sse.donate.subMany({
ping: () => {
router.refresh();
player.current?.play();
},
update: () => router.refresh(),
}).clean;
}, [router]);
if (sfx)
return (
<audio src="/assets/donate-mod-sfx.wav" preload="auto" ref={player} />
);
else return undefined;
}
@@ -0,0 +1,52 @@
"use client";
import { Button } from "@/components/ui/button";
import { sse } from "@/lib/db/sse-endpoints";
import { ExternalLink, RadioTower } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useRef, useState, type ComponentProps } from "react";
const ReactPIP = dynamic(() => import("react-document-picture-in-picture"), {
ssr: false,
});
export function PIP(props: ComponentProps<typeof ReactPIP>) {
return (
<ReactPIP
{...props}
width={400}
height={600}
shareStyles
buttonRenderer={({ toggle, isOpen }) => (
<div>
{isOpen ? <span>Content popped out.</span> : props.children}
<Button onClick={toggle} variant="outline" size="icon-sm">
<ExternalLink />
</Button>
</div>
)}
featureUnavailableRenderer={(r) => r}
/>
);
}
export function ClientTracker() {
const [connected, setConnected] = useState(0);
const tracking = useRef(0);
useEffect(
() => sse.donate.sub("heartbeat", () => tracking.current++).clean,
[],
);
useEffect(() => {
const interval = setInterval(() => {
setConnected(tracking.current);
if (tracking.current > 0) tracking.current--;
}, 10000);
return () => clearInterval(interval);
}, []);
return (
<Button variant="outline" disabled size="sm">
<RadioTower /> Connected: {connected}
</Button>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { adminCheck } from "@/lib/auth";
import { redirect } from "next/navigation";
import { DonateWatcher } from "../client";
import { db } from "@/lib/db";
import { donations } from "@/lib/db/schema";
import { desc } from "drizzle-orm";
import { Gavel, ImageOff, RefreshCw } from "lucide-react";
import { fileToDataUrl } from "@/lib/utils";
import Image from "@/components/image";
import { Spinner } from "@/components/ui/spinner";
import { ActionButton } from "@/components/action-button";
import { reloadWidget } from "../api";
import { markDone } from "@/app/widget/donate/api";
import { ClientTracker, PIP } from "./client";
export default async function DonateModeratorPage() {
if (!(await adminCheck()))
redirect(`/login?next=${encodeURIComponent("/donate/admin/moderator")}`);
const [sub] = await db
.select()
.from(donations)
.orderBy(desc(donations.created))
// .where(isNotNull(donations.image))
.limit(1);
async function reject() {
"use server";
markDone(sub.id);
reloadWidget();
}
return (
<PIP>
<div className="dark flex w-full max-w-100 flex-col gap-1 bg-background p-1 text-foreground scheme-dark">
<span>Donate Moderator</span>
{sub.image ? (
<div className="relative flex aspect-square w-full max-w-100 items-center justify-center overflow-hidden rounded-lg border bg-black/50">
<Image
src={await fileToDataUrl(
new File([Buffer.from(sub.image)], "abc.jpeg"),
)}
alt="donation image"
fill
className="z-10"
/>
<Spinner className="size-16" />
</div>
) : (
<div className="flex aspect-square items-center justify-center rounded-lg border bg-black/50">
<ImageOff className="size-16 opacity-20" />
</div>
)}
<div className="flex justify-between">
<div className="flex flex-col">
<span className="font-bold">{sub.name}</span>
<span>{sub.message}</span>
</div>
<span className="font-semibold">{sub.amount}฿</span>
</div>
<div className="flex flex-wrap justify-evenly gap-1">
<ActionButton action={reject} variant="destructive" size="sm">
<Gavel /> Reject
</ActionButton>
<ActionButton action={reloadWidget} variant="outline" size="sm">
<RefreshCw /> Reload Widgets
</ActionButton>
<ClientTracker />
</div>
<DonateWatcher sfx />
</div>
</PIP>
);
}