feat(don): add goal widget

This commit is contained in:
2026-06-15 12:14:38 +07:00 Unverified
parent ca8fe8e741
commit 25d8a6c2b4
8 changed files with 231 additions and 2 deletions
+3
View File
@@ -0,0 +1,3 @@
export default function () {
return null;
}
@@ -0,0 +1,84 @@
import { PlusIcon } from "lucide-react";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import z from "zod";
import { FormAction, FormInput, FormProvider } from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogClose, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { settings } from "@/lib/db/schema";
import { formParse } from "@/components/form-submit";
import { sse } from "@/lib/db/sse-endpoints";
const Schema = z.object({
goal: z.coerce.number().nonnegative().nullable().optional().default(null),
});
export default async function RgManualCreateModal() {
if (!(await adminCheck())) redirect("/login");
const [{ donateGoal }] = await db
.select({ donateGoal: settings.donateGoal })
.from(settings);
async function submit(form: FormData) {
"use server";
if (!(await adminCheck())) redirect("/login");
const { $, error } = formParse(Schema, form);
if (error) return { error };
await db
.insert(settings)
.values({ donateGoal: $.goal })
.onConflictDoUpdate({
target: settings.id,
set: { donateGoal: $.goal },
});
await actionLog(`Donate goal set to ${$.goal}`);
revalidatePath("/donate/admin");
sse.donate.pub("update", null);
return { toast: `Donate goal set to ${$.goal}`, close: true };
}
return (
<ModalBase title="Set Goal">
<FormProvider id="goal-set" onSubmit={submit}>
<FormInput name="goal" label="Price">
<Input
placeholder="0"
type="number"
defaultValue={donateGoal ?? ""}
/>
</FormInput>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Setting...
</>
}
>
<PlusIcon />
Set
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
export const dynamic = "force-dynamic";
+10
View File
@@ -14,6 +14,7 @@ import {
BugPlay,
ChevronsLeftRightEllipsis,
Copy,
Goal,
ImageIcon,
MessageCircleWarning,
} from "lucide-react";
@@ -24,6 +25,8 @@ import { useEffect } from "react";
import posthog from "posthog-js";
import { sse } from "@/lib/db/sse-endpoints";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import Link from "next/link";
const columns: ColumnDef<typeof donations.$inferSelect>[] = [
{ accessorKey: "name", header: "Name", meta: { className: "w-50 truncate" } },
@@ -89,6 +92,13 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
<ChevronsLeftRightEllipsis />
</ActionButton>
</SimpleTooltip>
<SimpleTooltip text="Set Goal">
<Button asChild variant="outline" size="icon-sm">
<Link href="/donate/admin/goal">
<Goal />
</Link>
</Button>
</SimpleTooltip>
</div>
);
},
+18
View File
@@ -0,0 +1,18 @@
import { ErrorModal } from "@/components/error";
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
import type { ReactNode } from "react";
export default async function DonateAdminLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
return (
<>
<ErrorBoundary errorComponent={ErrorModal}>{modal}</ErrorBoundary>
{children}
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use server";
import { db } from "@/lib/db";
import { donations, settings } from "@/lib/db/schema";
import { sum } from "drizzle-orm";
export async function getDonateBar() {
const [{ amount }] = await db
.select({
amount: sum(donations.amount),
})
.from(donations);
const [{ goal }] = await db
.select({ goal: settings.donateGoal })
.from(settings)
.limit(1);
return { amount, goal };
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { sse } from "@/lib/db/sse-endpoints";
import { useEffect, useRef, useState } from "react";
import { getDonateBar } from "./api";
type Data = Awaited<ReturnType<typeof getDonateBar>>;
export default function TopDonateWidget() {
const [bar, setBar] = useState<Data>();
const textRef = useRef<HTMLDivElement | null>(null);
const stableSize = useRef<Record<string, number>>({});
const [resizeTick, setResizeTick] = useState(0);
useEffect(() => {
async function update() {
void getDonateBar().then(setBar);
}
queueMicrotask(update);
const { clean } = sse.donate.subMany({
update,
ping: update,
refresh: () => location.reload(),
});
setInterval(update, 300000);
return () => {
clean();
};
}, []);
// the most idiomatic way possible but it works
useEffect(() => {
// oxlint-disable-next-line no-unused-expressions
resizeTick; // trigger re-run on window resize
if (!textRef.current) return;
var fontSize = 48,
count = 0;
const el = textRef.current,
parent = el.parentElement!,
key = `${bar?.goal}${bar?.amount}`;
if (stableSize.current[key]) {
el.style.fontSize = `${stableSize.current[key]}px`;
return;
}
const interval = setInterval(() => {
console.log(el.scrollHeight, parent.clientHeight);
fontSize = (fontSize * parent.clientHeight) / el.scrollHeight;
el.style.fontSize = `${fontSize}px`;
count++;
if (count > 20) {
stableSize.current[key] = fontSize;
clearInterval(interval);
}
}, 100);
return () => clearInterval(interval);
}, [bar, resizeTick]);
useEffect(() => {
const onResize = () => {
const key = `${bar?.goal}${bar?.amount}`;
delete stableSize.current[key];
setResizeTick((t) => t + 1);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [bar]);
return bar && bar.amount !== null && bar.goal ? (
<div className="relative h-full w-full rounded-full bg-[#252525]">
<div className="absolute inset-2 rounded-full bg-black" />
<div
className="absolute inset-1.5 rounded-full bg-linear-to-b from-[#FD0000] to-[#830000]"
style={{
right: `${100 - Math.min(1, Number(bar.amount) / bar.goal) * 100}%`,
}}
/>
<div
ref={textRef}
className="absolute top-1/2 right-8 -translate-y-1/2 pb-1 font-bold"
>
{bar.goal}฿
</div>
</div>
) : (
""
);
}
+8 -2
View File
@@ -16,11 +16,17 @@ export default function TopDonateWidget() {
void getTopDonate().then(setTop);
}
queueMicrotask(update);
return sse.donate.subMany({
const { clean } = sse.donate.subMany({
update,
ping: update,
refresh: () => location.reload(),
}).clean;
});
setInterval(update, 300000);
return () => {
clean();
};
}, []);
// the most idiomatic way possible but it works
+1
View File
@@ -75,6 +75,7 @@ export const versions = pgTable("versions", {
export const settings = pgTable("settings", {
id: boolean().primaryKey().default(true),
enka: boolean().notNull().default(false), // Turn this off when Enka is on maintenance
donateGoal: numeric({ mode: "number" }),
});
//#endregion