Compare commits
18
Commits
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ export const sse = sseEndpointMap({
|
||||
active: {
|
||||
version: z.string(),
|
||||
live: z.custom<YoutubeLiveInfo>(),
|
||||
refresh: z.string().nullable(),
|
||||
},
|
||||
// Admin Live Log
|
||||
log: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user