feat(don): add goal widget
This commit is contained in:
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
""
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user