18 Commits
26 changed files with 841 additions and 113 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ jobs:
-n buzz \
--dry-run=client -o json \
-- bun dr push | \
jq '.spec.template.spec.containers[0].envFrom = [{"secretRef": {"name": "buzz-env"}}]' | \
jq '.spec.template.spec.containers[0].envFrom = [{"secretRef": {"name": "buzz-env"}}] | .spec.template.spec.containers[0].imagePullPolicy = "Always"' | \
kubectl apply -f -
# Roll app & backend
+2 -1
View File
@@ -43,8 +43,9 @@ RUN mkdir -p .next/cache
FROM oven/bun:1.3.14-alpine AS migration
WORKDIR /home/container
RUN bun i uuidv7 drizzle-orm drizzle-kit
RUN bun i uuidv7 drizzle-orm drizzle-kit postgres
COPY --from=deps /home/container/package*.json ./
COPY --from=builder /home/container/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /home/container/lib ./lib
@@ -18,6 +18,7 @@ import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { guides } from "@/lib/db/schema";
import { forceRefresh } from "../../../settings/api";
export default async function GuideEditPage({
params,
@@ -66,6 +67,7 @@ export default async function GuideEditPage({
await actionLog(`Updated guide ${data.name}`, data);
revalidatePath("/admin/guide");
forceRefresh("/guide");
return { toast: "Guide updated successfully.", close: true };
}
@@ -18,6 +18,7 @@ import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { guides } from "@/lib/db/schema";
import { forceRefresh } from "../../../settings/api";
export default async function GuideCreatePage() {
if (!(await adminCheck())) redirect("/login");
@@ -64,6 +65,7 @@ export default async function GuideCreatePage() {
await actionLog(`Added guide ${data.name}`, data);
revalidatePath("/admin/guide");
forceRefresh("/guide");
return { toast: "Guide added successfully.", close: true };
}
+3
View File
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { guides } from "@/lib/db/schema";
import { forceRefresh } from "../settings/api";
export async function hideGuide(id: string) {
if (!(await adminCheck())) redirect("/login");
@@ -15,5 +16,7 @@ export async function hideGuide(id: string) {
hidden: not(guides.hidden),
})
.where(eq(guides.id, id));
forceRefresh("/guide");
revalidatePath("/admin/guide");
}
+30 -5
View File
@@ -7,15 +7,19 @@ import { revalidatePath } from "next/cache";
import { actionLog } from "@/lib/api";
import { db } from "@/lib/db";
import { settings } from "@/lib/db/schema";
import { adminCheck } from "@/lib/auth";
import { sse } from "@/lib/db/sse-endpoints";
export async function getEnka() {
const [{ enka } = { enka: true }] = await db.select().from(settings).limit(1);
return enka;
export async function getSettongs() {
if (!(await adminCheck())) throw "Unauthorized";
const [
settangs = { enka: true, donatePromptpay: true, donateTruemoney: true },
] = await db.select().from(settings).limit(1);
return settangs;
}
export async function toggleEnka(state: boolean) {
const last = await getEnka();
if (last === state) return;
if (!(await adminCheck())) throw "Unauthorized";
await db
.insert(settings)
.values({ enka: state })
@@ -27,9 +31,30 @@ export async function toggleEnka(state: boolean) {
}
export async function syncAmber() {
if (!(await adminCheck())) throw "Unauthorized";
const res = await promisify(exec)("bun util/sync 2>&1", {
env: { ...env, NO_AUTH_CHECK: "1" },
});
await actionLog("Triggered an Amber sync", { result: res.stdout });
return res.stdout;
}
export async function forceRefresh(prefix: string | null = null) {
if (!(await adminCheck())) throw "Unauthorized";
sse.active.pub("refresh", prefix);
await actionLog("Pulled a force refresh");
}
export async function toggleDonatePaymentMethod(
method: "donatePromptpay" | "donateTruemoney",
state: boolean,
) {
if (!(await adminCheck())) throw "Unauthorized";
await db
.insert(settings)
.values({ [method]: state })
.onConflictDoUpdate({ target: settings.id, set: { [method]: state } });
await actionLog("Changed a settings", { [method]: state });
}
+9 -3
View File
@@ -8,11 +8,11 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { SettingsAccountSection } from "./account";
import { getEnka } from "./api";
import { getSettongs } from "./api";
import { SettingsServicesSection } from "./services";
export default async function () {
const enka = await getEnka();
const { enka, donatePromptpay, donateTruemoney } = await getSettongs();
return (
<div className="grid h-full w-full gap-8 p-2 pl-0">
@@ -22,7 +22,11 @@ export default async function () {
<TabsTrigger value="account">บัญชี</TabsTrigger>
</TabsList>
<TabsContent value="external">
<SettingsServicesSection enka={enka} />
<SettingsServicesSection
enka={enka}
donPp={donatePromptpay}
donTmn={donateTruemoney}
/>
</TabsContent>
<TabsContent value="account">
<SettingsAccountSection />
@@ -49,3 +53,5 @@ export function Section({
</Card>
);
}
export const dynamic = "force-dynamic";
+75 -21
View File
@@ -1,48 +1,102 @@
"use client";
import { FolderSync } from "lucide-react";
import { useEffect, useState } from "react";
import { Bomb, FolderSync } from "lucide-react";
import { useState } from "react";
import { CdnChooser } from "@/components/chooser";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { shared } from "@/lib/comms";
import { syncAmber, toggleEnka } from "./api";
import {
forceRefresh,
syncAmber,
toggleDonatePaymentMethod,
toggleEnka,
} from "./api";
import { Section } from "./page";
import { ActionButton } from "@/components/action-button";
import { Input } from "@/components/ui/input";
export function SettingsServicesSection({
enka: enkaInitial,
donPp: initialDonPp,
donTmn: initialDonTmn,
}: {
enka: boolean;
donPp: boolean;
donTmn: boolean;
}) {
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState("");
const [enka, setEnka] = useState<boolean>();
const [enka, setEnka] = useState<boolean>(enkaInitial);
const [donPp, setDonPp] = useState<boolean>(initialDonPp);
const [donTmn, setDonTmn] = useState<boolean>(initialDonTmn);
const [pathToReload, setPathToReload] = useState("");
const [debug] = shared.state("debug");
useEffect(() => {
if (typeof enka !== "boolean") return setEnka(enkaInitial);
toggleEnka(enka);
}, [enka, enkaInitial]);
async function sync() {
setSyncing(true);
syncAmber()
.then(setSyncResult)
.finally(() => setSyncing(false));
return syncAmber().then(setSyncResult);
}
return (
<Section title="บริการนอก">
<div className="flex h-full flex-col gap-2">
<div className="flex items-center gap-2">
<Switch checked={enka} onCheckedChange={setEnka} />
<Switch
checked={enka}
onCheckedChange={(v) => (
setEnka(v),
toggleEnka(v).then(forceRefresh.bind(null, "/artifact"))
)}
/>
เปิดใช้การดึงตัวละครใน Artifact
</div>
<Button disabled={syncing} onClick={sync} className="w-min">
{syncing ? <Spinner /> : <FolderSync />}
Sync Amber
</Button>
<div className="flex items-center gap-2">
<ActionButton action={sync}>
<FolderSync />
Sync Amber
</ActionButton>
<ActionButton action={forceRefresh} variant="destructive">
<Bomb />
FORCE RELOAD
</ActionButton>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="/path/prefix/to/reload"
className="w-60"
onChange={(v) => setPathToReload(v.target.value)}
/>
<ActionButton
action={forceRefresh.bind(null, pathToReload)}
variant="destructive"
>
<Bomb />
Reload
</ActionButton>
</div>
<span>Donate Payment Method</span>
<div className="flex items-center gap-2">
<Switch
checked={donPp}
onCheckedChange={(v) => (
setDonPp(v),
toggleDonatePaymentMethod("donatePromptpay", v).then(
forceRefresh.bind(null, "/donate"),
)
)}
/>
Promptpay
</div>
<div className="flex items-center gap-2">
<Switch
checked={donTmn}
onCheckedChange={(v) => (
setDonTmn(v),
toggleDonatePaymentMethod("donateTruemoney", v).then(
forceRefresh.bind(null, "/donate"),
)
)}
/>
Truemoney
</div>
{debug && (
<div className="grid w-fit gap-1 rounded-sm border border-dashed p-1">
<span className="text-sm text-muted-foreground">
@@ -0,0 +1,228 @@
"use client";
import { format, getDaysInMonth, startOfMonth } from "date-fns";
import { th } from "date-fns/locale";
import {
ChevronLeft,
ChevronRight,
QrCodeIcon as PromptpayIcon,
} from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import TruemoneyIcon from "#/assets/tmn.webp";
import Image from "@/components/image";
import { SimpleTooltip } from "@/components/tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
type Row = {
id: string;
name: string;
amount: number;
message: string | null;
created: Date;
uid: string | null;
method: "tmn" | "pp";
image: boolean;
};
const DAYS = ["จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส.", "อา."];
export function CalendarClient({
year,
month,
rows,
total,
}: {
year: number;
month: number;
rows: Row[];
total: number;
}) {
const current = new Date(year, month);
const rowsByDay = useMemo(() => {
const map: Record<number, Row[]> = {};
for (const row of rows) {
const day = new Date(row.created).getDate();
(map[day] ??= []).push(row);
}
return map;
}, [rows]);
const daysInMonth = getDaysInMonth(current);
const firstDayOfWeek = startOfMonth(current).getDay();
const monthLabel = format(current, "MMMM yyyy", { locale: th });
const prevY = month === 0 ? year - 1 : year;
const prevM = month === 0 ? 11 : month - 1;
const nextY = month === 11 ? year + 1 : year;
const nextM = month === 11 ? 0 : month + 1;
const prevHref = `/donate/admin/calendar?month=${prevY}-${String(prevM + 1).padStart(2, "0")}`;
const nextHref = `/donate/admin/calendar?month=${nextY}-${String(nextM + 1).padStart(2, "0")}`;
return (
<div className="flex h-full min-h-[70svh] flex-col p-4 lg:min-h-[85svh]">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href={prevHref} prefetch replace>
<ChevronLeft />
</Link>
</Button>
<span className="inline-block min-w-35 shrink-0 text-center text-lg font-semibold capitalize">
{monthLabel}
</span>
<Button variant="outline" size="icon" asChild>
<Link href={nextHref} prefetch replace>
<ChevronRight />
</Link>
</Button>
</div>
<div className="mr-8 text-muted-foreground">
รวม{" "}
<span className="font-semibold text-foreground">
{total.toLocaleString()}
</span>{" "}
บาท
</div>
</div>
<div className="grid flex-1 grid-cols-7 grid-rows-[min-content_repeat(6,minmax(0,1fr))] gap-px rounded-lg bg-black/25">
{DAYS.map((day) => (
<div
key={day}
className="p-2 text-center text-xs font-medium text-muted-foreground"
>
{day}
</div>
))}
{Array.from(
{ length: firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1 },
(_, index) => `pad-${index + 1}`,
).map((key) => (
<div key={key} />
))}
{Array.from({ length: daysInMonth }, (_, index) => index + 1).map(
(day) => {
const dayRows = rowsByDay[day] || [];
const dayTotal = dayRows.reduce((sum, row) => sum + row.amount, 0);
const hasData = dayRows.length > 0;
return (
<Dialog key={day}>
<DialogTrigger asChild>
<button
type="button"
className={cn(
"flex flex-col items-start gap-0.5 rounded-md border p-2 text-left transition-colors",
hasData
? "cursor-pointer border-primary/30 bg-primary/5 hover:bg-primary/10"
: "border-transparent",
!hasData && "cursor-default",
)}
>
<span className="text-sm">{day}</span>
{hasData && (
<>
<Badge
variant="secondary"
className="h-4 px-1 py-0 text-[10px]"
>
{dayRows.length} รายการ
</Badge>
<span className="text-[10px] text-muted-foreground">
{dayTotal.toLocaleString()} ฿
</span>
</>
)}
</button>
</DialogTrigger>
<DialogContent className="max-h-[80svh] max-w-xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{format(
new Date(
current.getFullYear(),
current.getMonth(),
day,
),
"d MMM yyyy",
{ locale: th },
)}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
{dayRows.length === 0 && (
<p className="text-sm text-muted-foreground">
ไม่มีโดเนทในวันนี้
</p>
)}
{dayRows.map((row) => (
<div
key={row.id}
className="flex gap-2 rounded-md border p-3"
>
<div
className={cn(
"flex size-10 items-center justify-center rounded-sm",
row.method === "pp"
? "bg-[#0B5394]"
: "bg-[#FF8300]/50",
)}
>
{row.method === "pp" ? (
<SimpleTooltip text="PromptPay">
<PromptpayIcon className="size-8 p-1 opacity-50" />
</SimpleTooltip>
) : (
<SimpleTooltip text="TrueMoney">
<Image
src={TruemoneyIcon}
alt="truemoney"
className="size-8 p-1 opacity-50 brightness-200 grayscale"
/>
</SimpleTooltip>
)}
</div>
<div className="flex flex-1 items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">
{row.name}
</div>
<div className="text-xs text-muted-foreground">
{format(row.created, "HH:mm", { locale: th })}
{row.uid ? ` - UID ${row.uid}` : ""}
</div>
</div>
<span className="shrink-0 font-medium">
{row.amount.toLocaleString()} ฿
</span>
</div>
</div>
))}
</div>
<DialogFooter>
<span className="text-sm text-muted-foreground">
รวม {dayTotal.toLocaleString()} บาท
</span>
</DialogFooter>
</DialogContent>
</Dialog>
);
},
)}
</div>
</div>
);
}
@@ -0,0 +1,49 @@
import { endOfMonth, startOfMonth } from "date-fns";
import { between, getTableColumns, sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { ModalBase } from "@/components/modal";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { donations } from "@/lib/db/schema";
import { CalendarClient } from "./client";
export default async function DonateCalendarModal({
searchParams,
}: {
searchParams: Promise<{ month?: string }>;
}) {
if (!(await adminCheck())) {
redirect(`/login?next=${encodeURIComponent("/donate/admin/calendar")}`);
}
const { month } = await searchParams;
const date = month ? new Date(`${month}-01T00:00:00`) : new Date();
if (Number.isNaN(date.getTime())) redirect("/donate/admin");
const from = startOfMonth(date);
const to = endOfMonth(date);
const rows = await db
.select({
...getTableColumns(donations),
image: sql<boolean>`${donations.image} IS NOT NULL`,
})
.from(donations)
.where(between(donations.created, from, to))
.orderBy(donations.created);
const total = rows.reduce((sum, row) => sum + row.amount, 0);
return (
<ModalBase title="ปฏิทินโดเนท" full wrap>
<CalendarClient
year={date.getFullYear()}
month={date.getMonth()}
rows={rows}
total={total}
/>
</ModalBase>
);
}
export const dynamic = "force-dynamic";
+27 -4
View File
@@ -258,11 +258,14 @@ function TopRow({
<span>รวมวันนี้</span>
<span className="text-3xl font-bold">{stats.today}฿</span>
</div>
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border bg-card/50 px-2 py-1 ">
<Link
className="relative flex flex-1 flex-col overflow-hidden rounded-lg border bg-card/50 px-2 py-1"
href="/donate/admin/calendar"
>
<WalletIcon className="absolute top-1/3 left-1/3 size-6/8 opacity-20" />
<span>รวมทั้งหมด</span>
<span className="text-3xl font-bold">{stats.total}฿</span>
</div>
</Link>
</div>
);
}
@@ -294,7 +297,7 @@ export function DonateAdminPage({
<DataTable
columns={columns}
emptyDescription="No donation came in yet."
data={data.slice(1)}
data={data}
className="w-full overflow-y-auto bg-black/25 backdrop-blur-sm"
style={{
maxHeight,
@@ -308,10 +311,30 @@ export function DonateAdminPage({
// component cuz simplicity sake
export function DonateWatcher() {
const router = useRouter();
const [newDonates, setNewDonates] = useState(0);
useEffect(() => {
if (newDonates <= 0) return ((document.title = "โดเนททั้งหมด"), undefined);
var blink = true;
const interval = setInterval(() => {
blink = !blink;
document.title = blink ? "โดเนททั้งหมด" : `${newDonates} โดเนทใหม่`;
}, 1000);
return () => clearInterval(interval);
}, [newDonates]);
useEffect(() => {
function listener() {
setNewDonates(0);
}
window.addEventListener("focus", listener);
return () => window.removeEventListener("focus", listener);
}, []);
useEffect(() => {
if (!router) return;
return sse.donate.subMany({
ping: router.refresh.bind(router),
ping: () => (router.refresh(), setNewDonates((x) => x + 1)),
update: router.refresh.bind(router),
}).clean;
}, [router]);
+1 -1
View File
@@ -25,7 +25,7 @@ export default async function () {
today: sql<number>`
COALESCE(
SUM(${donations.amount}) FILTER (
WHERE ${donations.created} >= NOW() - INTERVAL '24 hours'
WHERE ${donations.created} >= date_trunc('day', NOW())
),
0
)
+42 -26
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { QrCodeIcon, SendIcon } from "lucide-react";
import { CircleX, QrCodeIcon, SendIcon } from "lucide-react";
import type { Metadata } from "next";
import z from "zod";
import { th } from "zod/v4/locales";
@@ -345,34 +345,50 @@ export default async function () {
]}
>
<FormChoice value="tmn">
<FormInput name="link" label="ลิ้งค์อั่งเปา">
<Input placeholder="https://gift.truemoney.com/campaign/?v=..." />
</FormInput>
{artifactConfig.donateTruemoney ? (
<FormInput name="link" label="ลิ้งค์อั่งเปา">
<Input placeholder="https://gift.truemoney.com/campaign/?v=..." />
</FormInput>
) : (
<div className="flex gap-2">
<CircleX className="text-red-500" />{" "}
ขณะนี้การโอนเงินด้วยทรูมันนี่ใช้ไม่ได้ชั่วคราว
</div>
)}
</FormChoice>
<FormChoice value="pp">
<div className="flex w-full gap-2 pb-2">
<Image
src={PromptpayImage}
alt="Promptpay QR Code"
className="max-w-32 shrink-0 rounded"
/>
<div className="relative flex shrink-0 flex-col">
<span className="text-sm font-bold">บัญชีรับโดเนท</span>
<span className="text-sm text-muted-foreground">
ผู้รับ: นาย พัชรพล พลพันธุ์
</span>
<span className="text-sm text-muted-foreground">
บัญชี: xxx-x-x8666-x
</span>
<span className="text-sm text-muted-foreground">
เลขที่อ้างอิง: 004999056945438
</span>
<DownloadButton />
{artifactConfig.donatePromptpay ? (
<>
<div className="flex w-full gap-2 pb-2">
<Image
src={PromptpayImage}
alt="Promptpay QR Code"
className="max-w-32 shrink-0 rounded"
/>
<div className="relative flex shrink-0 flex-col">
<span className="text-sm font-bold">บัญชีรับโดเนท</span>
<span className="text-sm text-muted-foreground">
ผู้รับ: นาย พัชรพล พลพันธุ์
</span>
<span className="text-sm text-muted-foreground">
บัญชี: xxx-x-x8666-x
</span>
<span className="text-sm text-muted-foreground">
เลขที่อ้างอิง: 004999056945438
</span>
<DownloadButton />
</div>
</div>
<FormInput name="slip">
<SlipUpload />
</FormInput>
</>
) : (
<div className="flex gap-2">
<CircleX className="text-red-500" />{" "}
ขณะนี้การโอนเงินด้วยพร้อมเพย์ใช้ไม่ได้ชั่วคราว
</div>
</div>
<FormInput name="slip">
<SlipUpload />
</FormInput>
)}
</FormChoice>
</FormTab>
<div className="flex justify-end">
+1 -1
View File
@@ -369,7 +369,7 @@ export async function getDiscordSession() {
export async function loginDiscord() {
redirect(
`https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(new URL("/rubgram/callback", BASE_URL).href)}&scope=identify+guilds.join+guilds`,
`https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(`${BASE_URL}/rubgram/callback`)}&scope=identify+guilds.join+guilds`,
);
}
+92
View File
@@ -0,0 +1,92 @@
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { redis } from "@/lib/db/redis";
import { env } from "bun";
import { sql } from "drizzle-orm";
async function probe(name: string, fn: () => Promise<unknown>) {
const started = performance.now();
try {
const result = await fn();
return {
name,
ok: true,
latencyMs: performance.now() - started,
result,
};
} catch (error) {
return {
name,
ok: false,
latencyMs: performance.now() - started,
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
cause: error.cause,
}
: error,
};
}
}
export async function GET(request: Request) {
if (!await adminCheck()) {
return new Response("Unauthorized", { status: 401 });
}
const probes = await Promise.all([
probe("redis", async () => {
const started = performance.now();
const result = await redis?.ping();
return {
result,
latencyMs: performance.now() - started,
};
}),
probe("postgres", async () => {
const started = performance.now();
const result = await db.execute(sql`SELECT 1`);
return {
result,
latencyMs: performance.now() - started,
};
}),
]);
return Response.json({
timestamp: new Date().toISOString(),
runtime: {
bun: Bun.version,
node: process.version,
platform: process.platform,
arch: process.arch,
pid: process.pid,
uptime: process.uptime(),
cwd: process.cwd(),
},
memory: process.memoryUsage(),
eventLoop: {
now: performance.now(),
},
request: {
url: request.url,
headers: Object.fromEntries(request.headers),
},
env,
probes,
});
}
+53 -11
View File
@@ -1,6 +1,9 @@
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { redis as redisShared } from "@/lib/db/redis";
import { GoogleGenAI } from "@google/genai";
import { env } from "bun";
const { DISCORD_BOT_TOKEN, GEMINI_TTS_API_KEY } = env as Record<string, string>;
const redis = redisShared!;
@@ -20,16 +23,22 @@ export async function GET() {
red
? (await redis.exists(`health:${k}`))
? (await redis.get(`health:${k}`)) === "ok"
: await v().then(async (v) => {
: await v().then(async (r: any) => {
const v = r instanceof Response ? r.ok : !!r;
if (v) await redis.setex(`health:${k}`, 900, "ok");
return v;
})
: await v(),
: await v().then((r: any) =>
r instanceof Response ? r.ok : !!r,
),
] as const,
),
),
);
return Response.json({ red, ...res });
return Response.json(
{ red, ...res },
{ status: res.database && red ? 200 : 201 },
);
}
const checks = {
@@ -40,19 +49,52 @@ const checks = {
.catch(() => false);
},
async enka() {
return await fetch("https://enka.network/api/uid/888888888/?info", {
return fetch("https://enka.network/api/uid/888888888/?info", {
headers: { "User-Agent": "Buzz, https://buzz.sudloh.com/api/health" },
signal: AbortSignal.timeout(5000),
})
.then((r) => r.ok)
.catch(() => false);
});
},
async amber() {
return await fetch("https://gi.yatta.moe/api/v2/en/avatar", {
return fetch("https://gi.yatta.moe/api/v2/en/avatar", {
headers: { "User-Agent": "Buzz, https://buzz.sudloh.com/api/health" },
signal: AbortSignal.timeout(5000),
})
.then((r) => r.ok)
});
},
async yt() {
return fetch("https://buzz.sudloh.com/api/live", {
signal: AbortSignal.timeout(5000),
});
},
async card() {
return fetch("https://api.astrxl.dev/v1/card/genshin/stats", {
signal: AbortSignal.timeout(5000),
});
},
async slip() {
return fetch("https://api.slipok.com/", {
signal: AbortSignal.timeout(5000),
});
},
async tts() {
const client = new GoogleGenAI({
apiKey: GEMINI_TTS_API_KEY.split(",").pop(),
});
return client.models
.list()
.then(() => true)
.catch(() => false);
},
} as const;
async tmn() {
return fetch("https://api.sastify.xyz/health", {
signal: AbortSignal.timeout(5000),
});
},
async discord() {
return fetch("https://discord.com/api/v10/users/@me", {
signal: AbortSignal.timeout(5000),
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
});
},
} satisfies Record<string, () => Promise<Response | boolean>>;
+16 -10
View File
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { sse } from "@/lib/db/sse-endpoints";
export const revalidate = 900;
import { youtubeCache } from "@/lib/adaptive-cache";
export type YoutubeLiveInfo =
| {
@@ -60,21 +59,23 @@ export async function GET() {
if (!apiKey || !channelId) return NextResponse.json<YoutubeLiveInfo>("none");
const response = await fetch(
const response = await youtubeCache.fetch(
`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`,
{
next: {
revalidate: 900, // refresh every 15 minutes
},
cache: "force-cache",
cache: "no-store",
},
);
if (!response.ok)
throw new Error(`YouTube API error: ${response.statusText}`);
if (!response.ok) {
console.error(`YouTube API error: ${response.statusText}`);
return NextResponse.json("none");
}
const data: APISearchResponse = await response.json();
if (data.items.length <= 0) return NextResponse.json<YoutubeLiveInfo>("none");
if (data.items.length === 0)
return NextResponse.json<YoutubeLiveInfo>("none");
const live = data.items[0];
const res: YoutubeLiveInfo = {
@@ -85,5 +86,10 @@ export async function GET() {
};
sse.active.pub("live", res);
return NextResponse.json<YoutubeLiveInfo>(res);
}
export const POST = GET;
export const revalidate = 60;
+31 -24
View File
@@ -42,36 +42,43 @@ export function VersionCheck({ headless = false }: { headless?: boolean }) {
useEffect(() => {
if (!readyForUpdate && window.location.hash === "#update") {
setUpdated(true);
setTimeout(() => toast.success("คุณอยู่ในเวอร์ชั่นล่าสุดแล้ว"), 1000);
if (!headless)
setTimeout(() => toast.success("คุณอยู่ในเวอร์ชั่นล่าสุดแล้ว"), 1000);
const url = new URL(window.location.href);
url.hash = "";
window.history.replaceState("", "", url);
}
const { clean, es } = sse.active.sub(
"version",
(newVersion) => {
setConnected(true);
emit("sync");
const version = ver.current;
if (!version) console.log("Client version:", newVersion);
console.log("Server version:", newVersion);
if (version && newVersion !== version) {
setReadyForUpdate(true);
const { clean, es } = sse.active.subMany(
{
version(newVersion) {
setConnected(true);
emit("sync");
const version = ver.current;
if (!version) console.log("Client version:", newVersion);
console.log("Server version:", newVersion);
if (version && newVersion !== version) {
setReadyForUpdate(true);
window.location.hash = "#update";
if (headless) window.location.reload();
else
toast("มีอัปเดตใหม่พร้อมใช้งาน", {
description: "รีโหลดเพื่ออัปเดตเป็นเวอร์ชันล่าสุด",
action: {
label: "รีโหลด",
// Do a full reload
onClick: () => window.location.reload(),
},
duration: Infinity,
});
}
if (newVersion !== version) ver.current = newVersion;
},
refresh(prefix) {
if (prefix && !window.location.pathname.startsWith(prefix)) return;
window.location.hash = "#update";
if (headless) window.location.reload();
else
toast("มีอัปเดตใหม่พร้อมใช้งาน", {
description: "รีโหลดเพื่ออัปเดตเป็นเวอร์ชันล่าสุด",
action: {
label: "รีโหลด",
// Do a full reload
onClick: () => window.location.reload(),
},
duration: Infinity,
});
}
if (newVersion !== version) ver.current = newVersion;
queueMicrotask(() => window.location.reload());
},
},
{
endpoint: "/api/active",
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { Countdown } from "@/app/(ui)/rubgram/client";
import type { YoutubeLiveInfo } from "@/app/api/live/route";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { throwNotOk } from "@/lib/utils";
import { RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { MessageCircleOff } from "lucide-react";
export default function ChatWidget() {
const [cooldown, setCooldown] = useState<Date>();
const [loading, setLoading] = useState(false);
const [notFound, setNotFound] = useState(false);
const router = useRouter();
const load = useCallback(async () => {
try {
setLoading(true);
// post prevents caching
const data: YoutubeLiveInfo = await fetch("/api/live", { method: "POST" })
.then(throwNotOk)
.then((r) => r.json());
if (data === "none") return setNotFound(true);
const vid = new URL(data.url).searchParams.get("v");
router.push(`https://studio.youtube.com/live_chat?is_popout=1&v=${vid}`);
} catch (e) {
console.error(e);
setNotFound(true);
} finally {
setLoading(false);
}
}, [router]);
useEffect(() => {
if (!notFound) load();
}, [load, notFound]);
return notFound ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-5 rounded-xl border bg-zinc-800/50 text-4xl">
<MessageCircleOff size={64} className="opacity-50" />
<Button
onClick={() => {
load().finally(() => setCooldown(new Date(Date.now() + 60_000)));
}}
size="lg"
disabled={!!cooldown || loading}
>
{loading ? <Spinner /> : <RefreshCw />}
{cooldown ? <Countdown time={cooldown} /> : "รีโหลดใหม่"}
</Button>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
);
}
+2 -2
View File
@@ -54,7 +54,7 @@
"drizzle-orm": "^0.44.7",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"lucide-react": "^1.27.0",
"motion": "^12.40.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
@@ -1053,7 +1053,7 @@
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"lucide-react": ["lucide-react@1.27.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw=="],
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+94
View File
@@ -0,0 +1,94 @@
// gpt'd at 3am, used as-is by another module
interface AdaptiveCacheOptions {
/** Maximum requests allowed per period. */
maxRequests: number;
/** Usually one day. */
period: number;
/** Rolling window used to estimate request rate. Defaults to period / 2. */
lookback?: number;
/** Enable caching once projected usage exceeds this fraction. Defaults to 0.5. */
threshold?: number;
/** How long cached entries live. */
ttl: number;
/** Cache key generator. */
key?(req: Request): string;
}
export class AdaptiveCache {
private readonly cache = new Map<
string,
{
expires: number;
response: Response;
}
>();
private readonly requests: number[] = [];
constructor(private readonly options: AdaptiveCacheOptions) {
options.lookback ??= options.period / 2;
options.threshold ??= 0.5;
options.key ??= (req) => req.url;
}
async fetch(
input: RequestInfo | URL,
init?: RequestInit,
fetcher: typeof fetch = fetch,
): Promise<Response> {
const req = input instanceof Request ? input : new Request(input, init);
const now = Date.now();
this.cleanup(now);
const key = this.options.key!(req);
if (this.shouldCache()) {
const cached = this.cache.get(key);
if (cached && cached.expires > now) return cached.response.clone();
}
const res = await fetcher(req);
if (res.ok) {
this.requests.push(now);
this.cache.set(key, {
expires: now + this.options.ttl,
response: res.clone(),
});
}
return res;
}
private shouldCache() {
const projected =
this.requests.length * (this.options.period / this.options.lookback!);
return projected >= this.options.maxRequests * this.options.threshold!;
}
private cleanup(now: number) {
const cutoff = now - this.options.lookback!;
while (this.requests.length && this.requests[0] < cutoff)
this.requests.shift();
for (const [key, value] of this.cache)
if (value.expires <= now) this.cache.delete(key);
}
}
export const youtubeCache = new AdaptiveCache({
maxRequests: 100, // YouTube search.list requests/day
period: 24 * 60 * 60_000,
ttl: 15 * 60_000,
});
+3 -1
View File
@@ -74,7 +74,9 @@ 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
enka: boolean().notNull().default(true), // Turn this off when Enka is on maintenance
donatePromptpay: boolean().notNull().default(true),
donateTruemoney: boolean().notNull().default(true),
donateGoal: numeric({ mode: "number" }),
donateGoalStarting: timestamp(),
});
+1
View File
@@ -48,6 +48,7 @@ export const sse = sseEndpointMap({
active: {
version: z.string(),
live: z.custom<YoutubeLiveInfo>(),
refresh: z.string().nullable(),
},
// Admin Live Log
log: {
+15
View File
@@ -72,3 +72,18 @@ export function parseSearchString(
export function pausePass<R>(time: number): (res: R) => Promise<R> {
return (res) => new Promise((r) => setTimeout(() => r(res), time));
}
// taken from astral/api
export class ResponseNotOkError extends Error {
constructor(
readonly status: number,
readonly response: Response,
) {
super(`RESPONSE_NOT_OK: Got status ${status}`);
}
}
export function throwNotOk(res: Response) {
if (!res.ok) throw new ResponseNotOkError(res.status, res);
return res;
}
+1 -1
View File
@@ -50,7 +50,7 @@ export default {
},
deploymentId: process.env.NEXT_DEPLOYMENT_ID,
// reactCompiler: true,
allowedDevOrigins: ["astral:3000", "dev3000.astrxl.dev"],
allowedDevOrigins: ["astral:3000", "dev3000.astrxl.dev", "nyx"],
// basePath: "/beta"
output: "standalone",
} satisfies NextConfig;
+1 -1
View File
@@ -68,7 +68,7 @@
"drizzle-orm": "^0.44.7",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"lucide-react": "^1.27.0",
"motion": "^12.40.0",
"next": "16.2.6",
"next-themes": "^0.4.6",