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,
|
BugPlay,
|
||||||
ChevronsLeftRightEllipsis,
|
ChevronsLeftRightEllipsis,
|
||||||
Copy,
|
Copy,
|
||||||
|
Goal,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
MessageCircleWarning,
|
MessageCircleWarning,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -24,6 +25,8 @@ import { useEffect } from "react";
|
|||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
||||||
{ accessorKey: "name", header: "Name", meta: { className: "w-50 truncate" } },
|
{ accessorKey: "name", header: "Name", meta: { className: "w-50 truncate" } },
|
||||||
@@ -89,6 +92,13 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
|||||||
<ChevronsLeftRightEllipsis />
|
<ChevronsLeftRightEllipsis />
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</SimpleTooltip>
|
</SimpleTooltip>
|
||||||
|
<SimpleTooltip text="Set Goal">
|
||||||
|
<Button asChild variant="outline" size="icon-sm">
|
||||||
|
<Link href="/donate/admin/goal">
|
||||||
|
<Goal />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</SimpleTooltip>
|
||||||
</div>
|
</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);
|
void getTopDonate().then(setTop);
|
||||||
}
|
}
|
||||||
queueMicrotask(update);
|
queueMicrotask(update);
|
||||||
return sse.donate.subMany({
|
const { clean } = sse.donate.subMany({
|
||||||
update,
|
update,
|
||||||
ping: update,
|
ping: update,
|
||||||
refresh: () => location.reload(),
|
refresh: () => location.reload(),
|
||||||
}).clean;
|
});
|
||||||
|
|
||||||
|
setInterval(update, 300000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clean();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// the most idiomatic way possible but it works
|
// the most idiomatic way possible but it works
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const versions = pgTable("versions", {
|
|||||||
export const settings = pgTable("settings", {
|
export const settings = pgTable("settings", {
|
||||||
id: boolean().primaryKey().default(true),
|
id: boolean().primaryKey().default(true),
|
||||||
enka: boolean().notNull().default(false), // Turn this off when Enka is on maintenance
|
enka: boolean().notNull().default(false), // Turn this off when Enka is on maintenance
|
||||||
|
donateGoal: numeric({ mode: "number" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|||||||
Reference in New Issue
Block a user