55 changed files with 1078 additions and 275 deletions
+19 -12
View File
@@ -36,8 +36,11 @@ jobs:
- run: |
TAG=${{ gitea.sha }}
docker build \
-t mts.dgnr.us:5000/${{ gitea.repository }}:frontend \
-t mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG \
-t registry.neko-piranha.ts.net/${{ gitea.repository }}:frontend \
-t registry.neko-piranha.ts.net/${{ gitea.repository }}:frontend-$TAG \
.
docker build --target migration \
-t registry.neko-piranha.ts.net/${{ gitea.repository }}:migration \
.
build-backend:
@@ -69,8 +72,8 @@ jobs:
TAG=${{ gitea.sha }}
docker build \
-f backend.dockerfile \
-t mts.dgnr.us:5000/${{ gitea.repository }}:backend \
-t mts.dgnr.us:5000/${{ gitea.repository }}:backend-$TAG \
-t registry.neko-piranha.ts.net/${{ gitea.repository }}:backend \
-t registry.neko-piranha.ts.net/${{ gitea.repository }}:backend-$TAG \
.
push:
@@ -79,10 +82,11 @@ jobs:
steps:
- run: |
TAG=${{ gitea.sha }}
docker push mts.dgnr.us:5000/${{ gitea.repository }}:backend &
docker push mts.dgnr.us:5000/${{ gitea.repository }}:backend-$TAG &
docker push mts.dgnr.us:5000/${{ gitea.repository }}:frontend &
docker push mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG &
docker push registry.neko-piranha.ts.net/${{ gitea.repository }}:backend &
docker push registry.neko-piranha.ts.net/${{ gitea.repository }}:backend-$TAG &
docker push registry.neko-piranha.ts.net/${{ gitea.repository }}:frontend &
docker push registry.neko-piranha.ts.net/${{ gitea.repository }}:frontend-$TAG &
docker push registry.neko-piranha.ts.net/${{ gitea.repository }}:migration &
wait
- name: Install kubectl
@@ -106,13 +110,16 @@ jobs:
# DB migration
kubectl delete job db-migrate -n buzz --ignore-not-found
kubectl create job db-migrate \
--image=mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG \
--image=registry.neko-piranha.ts.net/${{ gitea.repository }}:migration \
-n buzz \
-- bun dr push
--dry-run=client -o json \
-- bun dr push | \
jq '.spec.template.spec.containers[0].envFrom = [{"secretRef": {"name": "buzz-env"}}] | .spec.template.spec.containers[0].imagePullPolicy = "Always"' | \
kubectl apply -f -
# Roll app & backend
kubectl set image deployment/app app=mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG -n buzz
kubectl set image deployment/backend backend=mts.dgnr.us:5000/${{ gitea.repository }}:backend-$TAG -n buzz
kubectl set image deployment/app app=registry.neko-piranha.ts.net/${{ gitea.repository }}:frontend-$TAG -n buzz
kubectl set image deployment/backend backend=registry.neko-piranha.ts.net/${{ gitea.repository }}:backend-$TAG -n buzz
kubectl rollout status deployment/app -n buzz --timeout=180s &
kubectl rollout status deployment/backend -n buzz --timeout=180s &
kubectl wait --for=condition=complete job/db-migrate -n buzz --timeout=600s &
+23 -28
View File
@@ -1,20 +1,9 @@
# syntax=docker/dockerfile:1.4
### Stage 1: deps ###
FROM oven/bun:canary-alpine AS deps
FROM oven/bun:1.3.14-alpine AS deps
WORKDIR /home/container
# RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# --mount=type=cache,target=/var/lib/apt,sharing=locked \
# apt-get update \
# && apt-get install -y \
# python3 \
# make \
# g++ \
# sqlite3 \
# libsqlite3-dev \
# && rm -rf /var/lib/apt/lists/*
COPY package*.json bun.lock ./
COPY patches ./patches
@@ -22,7 +11,7 @@ COPY patches ./patches
RUN --mount=type=cache,target=/root/.bun bun install --frozen-lockfile
### Stage 2: builder ###
FROM oven/bun:canary-alpine AS builder
FROM oven/bun:1.3.14-alpine AS builder
# Isolation
RUN adduser -Du 1001 container
@@ -50,27 +39,33 @@ RUN --mount=type=cache,target=/home/container/.next/cache,uid=1001,gid=1001 \
# Fix nextjs caching(bind mount removes it after build process)
RUN mkdir -p .next/cache
### Stage 3: runner ###
FROM oven/bun:canary-alpine AS runner
### Stage 3: migration ###
FROM oven/bun:1.3.14-alpine AS migration
WORKDIR /home/container
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
COPY drizzle.config.ts ./
COPY lib/db/ ./lib/db/
CMD ["bun", "drizzle-kit"]
### Stage 4: runner ###
FROM oven/bun:1.3.14-alpine AS runner
# Isolation
RUN adduser -Du 1001 container
USER container
WORKDIR /home/container
COPY --from=builder /home/container/node_modules ./node_modules
COPY --from=builder /home/container/next.config.ts ./
COPY --from=builder /home/container/bun.lock ./
COPY --from=builder /home/container/package*.json ./
COPY --from=builder /home/container/.next/standalone ./
COPY --from=builder /home/container/public ./public
COPY --from=builder /home/container/.env ./.env
COPY --from=builder /home/container/.next ./.next
COPY --from=builder /home/container/util ./util
COPY --from=builder /home/container/lib ./lib
COPY --from=builder /home/container/tsconfig.json ./tsconfig.json
COPY --from=builder /home/container/.next/static ./.next/static
COPY --from=builder /home/container/.version ./.version
COPY --from=builder /home/container/drizzle.config.ts ./drizzle.config.ts
CMD ["bun", "start"]
ENV HOSTNAME=0.0.0.0
CMD ["bun", "server.js"]
+4 -4
View File
@@ -100,7 +100,7 @@ graph TB
subgraph Infra["Infrastructure — Docker Swarm"]
Nginx["Nginx Reverse Proxy<br/>(SSE: buffering off, 200s timeout)"]:::infra
GiteaCI["Gitea Actions CI/CD"]:::infra
Registry["Private Registry<br/>mts.dgnr.us:5000"]:::infra
Registry["Private Registry<br/>registry.neko-piranha.ts.net"]:::infra
AppService["App Service<br/>2 replicas · 1 CPU / 1 GB"]:::infra
BackendService["Backend Service<br/>1 replica · 1 CPU / 512 MB"]:::infra
end
@@ -270,7 +270,7 @@ graph TB
## Architecture
- **Framework:** Next.js 16 (App Router), Turbopack, React 19 — hybrid SSR/RSC/CSR
- **Runtime:** Bun (canary-alpine) — development, production, and backend
- **Runtime:** Bun (1.3.14-alpine) — development, production, and backend
- **Language:** TypeScript (strict, bundler module resolution)
- **UI:** shadcn/ui (Radix primitives), Tailwind CSS 4, motion, lucide-react, @tanstack/react-table, recharts
- **Forms:** react-hook-form, zod validation
@@ -397,7 +397,7 @@ Push to `main` or `dev` triggers `.github/workflows/build.yml`:
1. Build frontend Docker image (multi-stage: deps → drizzle push → builder → runner)
2. Build backend Docker image (compiled to Bun bytecode via `bun build --target bun --bytecode`)
3. Push both images to private registry at `mts.dgnr.us:5000`
3. Push both images to private registry at `registry.neko-piranha.ts.net`
4. Rolling update of Docker Swarm services (`buzz_app`, `buzz_backend`)
### Production stack (Docker Swarm)
@@ -412,7 +412,7 @@ Infrastructure dependencies:
- PostgreSQL database (managed externally)
- Redis instance for SSE pub/sub and caching
- Nginx reverse proxy with SSE optimizations (buffering off, 200s read timeout)
- Private Docker registry at `mts.dgnr.us:5000`
- Private Docker registry at `registry.neko-piranha.ts.net`
### Environment
@@ -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">
+13 -11
View File
@@ -9,7 +9,7 @@ import {
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { Suspense } from "react";
import { Suspense, ViewTransition } from "react";
import banner from "#/logos/artifact.webp";
import { Blocker } from "@/components/blocker";
import { ComboBox } from "@/components/combobox";
@@ -138,16 +138,18 @@ export default async function ArtifactFormPage({
<CardTitle>
<div className="w-[276.5px]">
<Link href="/">
<Image
style={{
transform: "translateY(-70%)",
}}
className="absolute z-50"
height={137.5}
width={276.5}
src={banner}
alt="เสือกไอดีชาวบ้าน"
/>
<ViewTransition name="branch-/artifact">
<Image
style={{
transform: "translateY(-70%)",
}}
className="absolute z-50"
height={137.5}
width={276.5}
src={banner}
alt="เสือกไอดีชาวบ้าน"
/>
</ViewTransition>
</Link>
</div>
</CardTitle>
+72
View File
@@ -0,0 +1,72 @@
"use client";
import Image from "next/image";
import Background0 from "#/bg.webp";
import Background1 from "#/bgassets/buzzbg1-r2.webp";
import Background2 from "#/bgassets/buzzbg2-r1.webp";
import Background3 from "#/bgassets/buzzbg3-r1.webp";
import Background4 from "#/bgassets/buzzbg4-r1.webp";
import Background5 from "#/bgassets/buzzbg5-r1.webp";
import Background6 from "#/bgassets/buzzbg6-r1.webp";
import Background7 from "#/bgassets/buzzbg7-r1.webp";
import type { StaticImport } from "next/dist/shared/lib/get-img-props";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import { StarsRenderer } from "@/components/stars";
type PrefixBackground = [`/${string}`, string | StaticImport, `/${string}`];
const backgrounds = [
// path prefix, background, stars mask
["/artifact", Background1, "/bgassets/buzzbg1-r2-mask.webp"],
["/rubgram", Background2, "/bgassets/buzzbg2-r1-mask.webp"],
["/donate", Background3, "/bgassets/buzzbg3-r1-mask.webp"],
["/tl", Background5, "/bgassets/buzzbg4-r1-mask.webp"],
["/guide", Background4, "/bgassets/buzzbg5-r1-mask.webp"],
["/admin", Background6, "/bgassets/buzzbg6-r1-mask.webp"],
// unused
["/donate", Background7, "/bgassets/buzzbg7-r1-mask.webp"],
// this should be last
["/", Background0, "/mask.webp"],
] satisfies PrefixBackground[];
export function BackgroundProvider() {
const pathname = usePathname();
const [, background, mask] = useMemo(
() => backgrounds.find((b) => pathname.startsWith(b[0]))!,
[pathname],
);
const [transitioning, setTransitioning] = useState(false);
const [current, setCurrent] = useState<string | StaticImport>(background);
return (
<>
<Image
src={background}
alt="Background Swap"
className={cn(
"fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full object-cover opacity-0",
transitioning && "opacity-40 transition-opacity duration-500",
)}
onLoad={() => {
if (background === current) return;
setTransitioning(true);
}}
onTransitionEnd={() => {
if (!transitioning) return;
setCurrent(background);
setTransitioning(false);
}}
/>
<Image
src={current}
alt="Background Current"
className={cn(
"fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full object-cover opacity-40",
transitioning && "opacity-0 transition-opacity duration-500",
)}
/>
{transitioning || <StarsRenderer mask={mask} />}
</>
);
}
@@ -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";
+182 -56
View File
@@ -5,15 +5,18 @@ import {
BitcoinIcon,
BugPlay,
ChevronsLeftRightEllipsis,
CoinsIcon,
Copy,
Goal,
ImageIcon,
MessageCircleWarning,
WalletIcon,
QrCodeIcon as PromptpayIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useEffect } from "react";
import { type ComponentProps, useEffect, useRef, useState } from "react";
import { useEllipsisVisible } from "react-hook-text-overflow";
import { DataTable } from "@/components/tantable";
import { SimpleTooltip } from "@/components/tooltip";
@@ -27,8 +30,38 @@ import type { donations } from "@/lib/db/schema";
import { sse } from "@/lib/db/sse-endpoints";
import { ActionButton } from "../../../../components/action-button";
import { getImage, reloadWidget, resendPopup, testPopup } from "./api";
import { formatDistanceToNow } from "date-fns";
import { lePalette } from "../../rubgram/admin/[id]/client";
import { th } from "date-fns/locale";
import TruemoneyIcon from "#/assets/tmn.webp";
import Image from "@/components/image";
// directly adapted for date instead of UUID
function colorFor(date: Date) {
const seed =
date.getUTCFullYear() * 10000 +
(date.getUTCMonth() + 1) * 100 +
date.getUTCDate();
return lePalette[seed % lePalette.length];
}
const columns: ColumnDef<typeof donations.$inferSelect>[] = [
{
accessorKey: "created",
header: "",
cell(row) {
return (
<div
className="absolute inset-0"
style={{
background: colorFor(row.row.original.created).bg,
}}
></div>
);
},
meta: { className: "w-1 p-0 relative" },
},
{ accessorKey: "name", header: "ชื่อ", meta: { className: "w-50 truncate" } },
{
accessorFn: (row) => `${row.amount}฿`,
@@ -103,79 +136,172 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
);
},
cell(row) {
return (
<div className="flex gap-1">
<SimpleTooltip text="แสดง Popup อีกครั้ง">
<ActionButton
variant="outline"
size="icon-sm"
action={() => {
posthog.capture("donation_admin_resend", {
id: row.row.original.id,
});
return resendPopup(row.row.original.id);
}}
>
<MessageCircleWarning />
</ActionButton>
</SimpleTooltip>
{row.row.original.uid ? (
<SimpleTooltip text="ก็อบ UID">
<ActionButton
size="icon-sm"
action={() =>
navigator.clipboard.writeText(row.row.original.uid!)
}
>
<Copy />
</ActionButton>
</SimpleTooltip>
) : (
<div className="size-8" />
)}
{row.row.original.image && (
<SimpleTooltip text="เปิดภาพ">
<ActionButton
size="icon-sm"
variant="outline"
action={async () => {
await getImage(row.row.original.id).then((blob) => {
const url = URL.createObjectURL(blob);
const tab = window.open("about:blank", "_blank");
if (tab) {
tab.location.href = url;
}
});
}}
>
<ImageIcon />
</ActionButton>
</SimpleTooltip>
)}
</div>
);
return <ActionRow row={row.row.original} filler />;
},
meta: { className: "w-27 p-0" },
meta: { className: "w-36 p-0" },
},
];
function ActionRow({
row,
filler,
}: {
row: typeof donations.$inferSelect;
filler?: boolean;
}) {
return (
<div className="flex gap-1">
{row.method === "pp" ? (
<PromptpayIcon className="size-8 p-1 opacity-50" />
) : (
<Image
src={TruemoneyIcon}
alt="truemoney"
className="size-8 p-1 opacity-50 brightness-200 grayscale"
/>
)}
<SimpleTooltip text="แสดง Popup อีกครั้ง">
<ActionButton
variant="outline"
size="icon-sm"
action={() => {
posthog.capture("donation_admin_resend", {
id: row.id,
});
return resendPopup(row.id);
}}
>
<MessageCircleWarning />
</ActionButton>
</SimpleTooltip>
{row.uid ? (
<SimpleTooltip text="ก็อบ UID">
<ActionButton
size="icon-sm"
action={() => navigator.clipboard.writeText(row.uid!)}
>
<Copy />
</ActionButton>
</SimpleTooltip>
) : filler ? (
<div className="size-8" />
) : undefined}
{row.image && (
<SimpleTooltip text="เปิดภาพ">
<ActionButton
size="icon-sm"
variant="outline"
action={async () => {
await getImage(row.id).then((blob) => {
const url = URL.createObjectURL(blob);
const tab = window.open("about:blank", "_blank");
if (tab) {
tab.location.href = url;
}
});
}}
>
<ImageIcon />
</ActionButton>
</SimpleTooltip>
)}
</div>
);
}
function TopRow({
row,
stats,
...props
}: {
row?: typeof donations.$inferSelect;
stats: {
total: number;
today: number;
};
} & ComponentProps<"div">) {
const [time, setTime] = useState("");
useEffect(() => {
if (!row) return setTime("");
function update() {
setTime(`${formatDistanceToNow(row!.created, { locale: th })}ที่แล้ว`);
}
const interval = setInterval(update, 1000);
update();
return () => clearInterval(interval);
}, [row]);
return (
<div className="flex w-full gap-2 p-1" {...props}>
{row ? (
<div className="flex flex-10 flex-col rounded-lg border bg-card/50 py-1 pr-1 pl-2">
<div className="flex justify-between text-xl">
<div className="flex gap-2">
<span className="font-semibold">{row.name}</span>
<span className="opacity-70">{row.amount}฿</span>
</div>
<span className="pr-1 opacity-70">{time}</span>
</div>
<div className="min-h-20">{row.message}</div>
<div className="flex justify-end">
<ActionRow row={row} />
</div>
</div>
) : (
<div className="flex-10 items-center justify-center rounded-lg border bg-card/50 px-2 py-1">
<span>No donation yet</span>
</div>
)}
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border bg-card/50 px-2 py-1">
<CoinsIcon className="absolute top-1/3 left-1/3 size-6/8 opacity-20" />
<span>รวมวันนี้</span>
<span className="text-3xl font-bold">{stats.today}฿</span>
</div>
<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>
</Link>
</div>
);
}
export function DonateAdminPage({
data,
stats,
}: {
data: (typeof donations.$inferSelect)[];
stats: {
total: number;
today: number;
};
}) {
const [maxHeight, setMaxHeight] = useState("");
const topRowRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setMaxHeight(`calc(100vh - 93px - ${topRowRef.current?.clientHeight}px)`);
}, [data]);
return (
<div className="mx-auto flex w-full max-w-[max(1280px,90%)] flex-col">
<span className="flex items-center gap-1 pt-1 pb-2 text-3xl font-semibold">
<BitcoinIcon size={32} />
โดเนททั้งหมด
</span>
<TopRow row={data[0]} ref={topRowRef} stats={stats} />
<DataTable
columns={columns}
emptyDescription="No donation came in yet."
data={data}
className="max-h-[calc(100vh-93px)] w-full overflow-y-auto bg-black/25 backdrop-blur-sm"
data={data.slice(1)}
className="w-full overflow-y-auto bg-black/25 backdrop-blur-sm"
style={{
maxHeight,
}}
></DataTable>
<DonateWatcher />
</div>
+26 -10
View File
@@ -1,4 +1,4 @@
import { desc, getTableColumns, sql } from "drizzle-orm";
import { desc, getTableColumns, sql, sum } from "drizzle-orm";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { adminCheck } from "@/lib/auth";
@@ -10,15 +10,31 @@ export default async function () {
if (!(await adminCheck()))
redirect(`/login?next=${encodeURIComponent("/donate/admin")}`);
const data = await db
.select({
...getTableColumns(donations),
image: sql<Buffer>`${donations.image} IS NOT NULL`,
})
.from(donations)
.limit(100)
.orderBy(desc(donations.id));
return <DonateAdminPage data={data} />;
const [data, [stats]] = await Promise.all([
db
.select({
...getTableColumns(donations),
image: sql<Buffer>`${donations.image} IS NOT NULL`,
})
.from(donations)
.limit(100)
.orderBy(desc(donations.id)),
db
.select({
total: sum(donations.amount).mapWith(Number),
today: sql<number>`
COALESCE(
SUM(${donations.amount}) FILTER (
WHERE ${donations.created} >= date_trunc('day', NOW())
),
0
)
`,
})
.from(donations)
.limit(1),
]);
return <DonateAdminPage data={data} stats={stats} />;
}
export const metadata: Metadata = {
+51 -31
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";
@@ -37,6 +37,7 @@ import {
} from "../rubgram/admin/@modal/manual/client";
import { DownloadButton } from "../rubgram/client";
import Link from "next/link";
import { ViewTransition } from "react";
const { TMN_DEST_PHONE_NUM, SASTIFY_API_PRIVKEY } = process.env as Record<
string,
@@ -205,6 +206,7 @@ export default async function () {
amount,
message,
image: downscaled,
method: $.type,
uid: $.artifact === "true" ? $.uid : null,
// dont send on screen if less than 10
sent: $.amount < 10,
@@ -272,11 +274,13 @@ export default async function () {
{/* oxlint-disable-next-line tailwindcss/enforce-canonical */}
<div className="relative aspect-[304.5/30] w-full">
<Link href="/">
<Image
src={DonateLogo}
alt="Donate Logo"
className="absolute left-1/2 w-3/4 -translate-x-1/2 -translate-y-2/3"
/>
<ViewTransition name="branch-/donate">
<Image
src={DonateLogo}
alt="Donate Logo"
className="absolute left-1/2 w-3/4 -translate-x-1/2 -translate-y-2/3"
/>
</ViewTransition>
</Link>
</div>
<FormProvider id="tip" inDialog={false} onSubmit={submit}>
@@ -344,34 +348,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">
+2 -9
View File
@@ -1,11 +1,9 @@
import { Anuphan } from "next/font/google";
import { ViewTransition } from "react";
import { Toaster } from "sonner";
import Background from "#/bg.webp";
import Image from "@/components/image";
import { StarsRenderer } from "@/components/stars";
import Providers, { VersionCheck } from "../client";
import { Navbar } from "./navbar";
import { BackgroundProvider } from "./background";
const anuphan = Anuphan({
variable: "--font-anuphan",
@@ -19,12 +17,7 @@ export default async function UiLayout({
}) {
return (
<>
<Image
src={Background}
alt="Background"
className="fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full object-cover opacity-40"
/>
<StarsRenderer />
<BackgroundProvider />
<Providers>
<ViewTransition>{children}</ViewTransition>
<VersionCheck />
+10 -8
View File
@@ -1,6 +1,6 @@
import type { StaticImport } from "next/dist/shared/lib/get-img-props";
import Link from "next/link";
import type { ReactNode } from "react";
import { ViewTransition, type ReactNode } from "react";
import Banner from "#/banner_t.webp";
import ArtifactLogo from "#/logos/artifact.webp";
import DonateLogo from "#/logos/donate.webp";
@@ -159,13 +159,15 @@ function HomeLink({
className,
)}
>
<Image
src={logo}
alt={name}
className="h-24 w-48 object-contain transition-transform duration-100 hover:scale-110 sm:h-40 sm:w-50"
width={200}
height={200}
/>
<ViewTransition name={`branch-${href}`}>
<Image
src={logo}
alt={name}
className="h-24 w-48 object-contain transition-transform duration-100 hover:scale-110 sm:h-40 sm:w-50"
width={200}
height={200}
/>
</ViewTransition>
<span className="visible block text-lg md:hidden md:text-xs">
{name}
</span>
@@ -209,6 +209,7 @@ export function UserSelect({
variant="outline"
onClick={() => setBump(Date.now())}
disabled={loading}
type="button"
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
+1 -1
View File
@@ -23,7 +23,7 @@ import type { Note } from "@/lib/db/schema";
import { addNote, debugUploadSlip, deleteNote, discordCall } from "../../api";
// by chatgpt
const lePalette = [
export const lePalette = [
{
bg: "#A34A5B",
border: "#C26779",
+13 -11
View File
@@ -10,7 +10,7 @@ import {
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { Suspense } from "react";
import { Suspense, ViewTransition } from "react";
import PromptpayImage from "#/assets/promptpay.jpg";
import banner from "#/logos/rubgram.webp";
import { Blocker } from "@/components/blocker";
@@ -181,16 +181,18 @@ export default async function EndgamePage({
<CardTitle>
<div className="w-[276.5px]">
<Link href="/">
<Image
style={{
transform: "translateY(-70%)",
}}
className="absolute z-50"
height={137.5}
width={276.5}
src={banner}
alt="รับกรรมแทนทางบ้าน"
/>
<ViewTransition name="branch-/rubgram">
<Image
style={{
transform: "translateY(-70%)",
}}
className="absolute z-50"
height={137.5}
width={276.5}
src={banner}
alt="รับกรรมแทนทางบ้าน"
/>
</ViewTransition>
</Link>
</div>
</CardTitle>
+11 -8
View File
@@ -9,6 +9,7 @@ import { SimpleTooltip } from "@/components/tooltip";
import { ScrollBar } from "@/components/ui/scroll-area";
import { db } from "@/lib/db";
import { tierlistTypes, tierlistVersions } from "@/lib/db/schema";
import { ViewTransition } from "react";
export const metadata: Metadata = {
title: "จัดเทียร์ลิสต์",
@@ -47,14 +48,16 @@ export default async function TierlistSelectionPage() {
<div className="mx-2 flex min-h-full max-w-full flex-col justify-center gap-2">
<center className="mb-2">
<Link href="/">
<Image
src={TierlistLogo}
alt="Tierlist"
className="w-1/2 sm:w-96"
width={200}
height={100}
fetchPriority="high"
/>
<ViewTransition name="branch-/tl">
<Image
src={TierlistLogo}
alt="Tierlist"
className="w-1/2 sm:w-96"
width={200}
height={100}
fetchPriority="high"
/>
</ViewTransition>
</Link>
</center>
{vers.map((t) => (
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
type LiveResponse =
| {
url: string;
}
| "none";
export async function GET(request: Request) {
const liveUrl = new URL("https://buzz.sudloh.com/api/live", request.url).toString();
const response = await fetch(liveUrl, {
cache: "no-store",
});
if (!response.ok) {
return new NextResponse("Failed to get live stream.", {
status: 502,
});
}
const liveInfo: LiveResponse = await response.json();
if (liveInfo === "none") {
return new NextResponse("No live stream found for this channel.", {
status: 404,
});
}
const videoId = new URL(liveInfo.url).searchParams.get("v");
if (!videoId) {
return new NextResponse("Live stream video id was not found.", {
status: 502,
});
}
return NextResponse.redirect(
`https://studio.youtube.com/live_chat?is_popout=1&v=${videoId}`,
);
}
+8 -8
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,13 +59,10 @@ 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",
},
);
@@ -74,7 +70,10 @@ export async function GET() {
throw new Error(`YouTube API error: ${response.statusText}`);
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 +84,6 @@ export async function GET() {
};
sse.active.pub("live", res);
return NextResponse.json<YoutubeLiveInfo>(res);
}
+27 -3
View File
@@ -10,9 +10,17 @@ const { DONATE_WIDGET_KEY, GEMINI_TTS_API_KEY } = process.env as Record<
string
>;
const MODELS = [
"gemini-2.5-flash-preview-tts",
"gemini-3.1-flash-preview-tts",
] as const;
const API_KEYS = GEMINI_TTS_API_KEY.split(",")
.map((k) => k.trim())
.filter(Boolean);
const POSSIBLE_CHOICES = MODELS.flatMap((m) =>
API_KEYS.map((k) => `${k}:${m}` as `${string}:${(typeof MODELS)[number]}`),
);
const MAX_TRIES = 10;
const Schema = z.object({
message: z.string().max(1000),
@@ -33,19 +41,33 @@ export async function GET(request: Request) {
const textHash = hash(message).toString(36);
const cached = await redis!.get(`tts:${textHash}`);
console.log(
`[TTS] ${textHash}:${cached ? "cached" : "fresh"} "${message.slice(0, 200)}${message.length > 200 ? "..." : ""}"`,
);
if (cached)
return new Response(pcmToWav(cached), {
headers: { "Content-Type": "audio/wav" },
});
let audioData: string | undefined;
for (const apiKey of API_KEYS) {
let currentRotation = await redis!
.get("tts_key_rotation")
.then((e) => (e ? Number(e) : 0))
.catch(() => 0);
for (
let i = 0;
i < MAX_TRIES;
i++, currentRotation = (currentRotation + 1) % POSSIBLE_CHOICES.length
) {
const [apiKey, model] = POSSIBLE_CHOICES[currentRotation].split(":");
console.log(
`[TTS] Trying key ${apiKey.slice(0, 8)}:${model} (${currentRotation}/${POSSIBLE_CHOICES.length} #${i + 1})...`,
);
const client = new GoogleGenAI({ apiKey });
try {
const response = await client.models.generateContent({
model: "gemini-2.5-flash-preview-tts",
model,
contents: [{ parts: [{ text: message }] }],
config: {
responseModalities: ["AUDIO"],
@@ -65,11 +87,13 @@ export async function GET(request: Request) {
console.error(`TTS API key failed: ${apiKey.slice(0, 8)}...`, e);
}
}
console.log("[TTS] Got response?", audioData ? "yes" : "no");
if (!audioData)
return Response.json({ error: "Response is empty" }, { status: 500 });
queueMicrotask(() => {
redis!.setex(`tts:${textHash}`, 604800, audioData);
redis!.setex("tts_key_rotation", 604800, `${currentRotation}`);
});
return new Response(pcmToWav(audioData), {
headers: { "Content-Type": "audio/wav" },
+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",
+3 -3
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.4
### Stage 1: deps ###
FROM oven/bun:canary-alpine AS deps
FROM oven/bun:1.3.14-alpine AS deps
WORKDIR /home/container
COPY package*.json bun.lock ./
@@ -11,7 +11,7 @@ COPY patches ./patches
RUN --mount=type=cache,target=/root/.bun bun install --frozen-lockfile
### Stage 2: builder ###
FROM oven/bun:canary-alpine AS builder
FROM oven/bun:1.3.14-alpine AS builder
# Isolation
RUN adduser -Du 1001 container
@@ -29,7 +29,7 @@ COPY --chown=1001 . .
RUN bun build --target bun --production --bytecode backend --outdir=dist
### Stage 3: runner ###
FROM oven/bun:canary-alpine AS runner
FROM oven/bun:1.3.14-alpine AS runner
# Isolation
RUN adduser -Du 1001 container
+5 -2
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export function StarsRenderer() {
export function StarsRenderer({ mask }: { mask: string }) {
const cv = useRef<HTMLCanvasElement | null>(null);
const [loaded, setLoaded] = useState(false);
@@ -122,9 +122,12 @@ export function StarsRenderer() {
<canvas
ref={cv}
className={cn(
"fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full mask-[url('/mask.webp')] mask-cover mask-center mask-no-repeat object-cover opacity-0 transition-opacity duration-5000",
"fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full mask-cover mask-center mask-no-repeat object-cover opacity-0 transition-opacity duration-5000",
loaded && "opacity-40",
)}
style={{
maskImage: `url(${JSON.stringify(mask)})`,
}}
></canvas>
);
}
+7 -2
View File
@@ -9,6 +9,7 @@ import {
} from "@tanstack/react-table";
import { FolderCode, Plus } from "lucide-react";
import Link from "next/link";
import type { ComponentProps } from "react";
import { type MouseEventHandler, type ReactNode, useState } from "react";
import {
Table,
@@ -51,6 +52,7 @@ export function DataTable<TData, TValue>({
home,
context,
onClick,
style,
}: DataTableProps<TData, TValue> & {
className?: string;
openCreateDialog?: () => void;
@@ -66,7 +68,7 @@ export function DataTable<TData, TValue>({
icon?: ReactNode;
}[];
onClick?: (row: TData) => void;
}) {
} & Pick<ComponentProps<"div">, "style">) {
const [internalRowSelection, setInternalRowSelection] = useState({});
const table = useReactTable({
@@ -80,7 +82,10 @@ export function DataTable<TData, TValue>({
});
return (
<div className={cn("overflow-hidden rounded-md border", className)}>
<div
className={cn("overflow-hidden rounded-md border", className)}
style={style}
>
<Table className="table-fixed">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+2 -2
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.4
### Stage 1: deps ###
FROM oven/bun:canary-alpine AS deps
FROM oven/bun:1.3.14-alpine AS deps
WORKDIR /home/container
COPY package*.json bun.lock ./
@@ -11,7 +11,7 @@ COPY patches ./patches
RUN --mount=type=cache,target=/root/.bun bun install --frozen-lockfile
### Stage 2: builder ###
FROM oven/bun:canary-alpine AS builder
FROM oven/bun:1.3.14-alpine AS builder
# Isolation
RUN adduser -Du 1001 container
+2 -2
View File
@@ -2,7 +2,7 @@ version: "3.9"
services:
app:
image: mts.dgnr.us:5000/astral/buzz:frontend
image: registry.neko-piranha.ts.net/astral/buzz:frontend
deploy:
replicas: 2
resources:
@@ -26,7 +26,7 @@ services:
- buzz
backend:
image: mts.dgnr.us:5000/astral/buzz:backend
image: registry.neko-piranha.ts.net/astral/buzz:backend
environment:
NO_AUTH_CHECK: "true"
deploy:
+1 -1
View File
@@ -2,7 +2,7 @@ import posthog from "posthog-js";
if (process.env.NODE_ENV === "production")
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: "/i",
api_host: "https://s.astrxl.dev",
ui_host: "https://us.posthog.com",
defaults: "2026-01-30",
capture_exceptions: true,
+5 -6
View File
@@ -55,7 +55,7 @@
- **ServiceAccount `ci-deployer`** in namespace `buzz`.
- RBAC Role grants: Deployments (get/list/watch/update/patch), Pods (get/list/watch), Jobs (full CRUD), Services/Ingresses/Middlewares (get/list/watch/create/update/patch).
- Token for the runner: `kubectl create token ci-deployer -n buzz` (or use a long-lived token from a Secret tied to the SA).
- The runner also needs `docker` login access to `mts.dgnr.us:5000` (same as today).
- The runner also needs `docker` login access to `registry.neko-piranha.ts.net` (same as today).
### Replicated Stateful Services (external to buzz namespace)
@@ -65,8 +65,7 @@
### Container Registry
- **Zot** running in-cluster (namespace `registry`), exposed via ServiceLB on host port 5000.
- All k3s nodes have `/etc/rancher/k3s/registries.yaml` mirroring `mts.dgnr.us:5000` → `http://localhost:5000` (svclb binds host port 5000). k3s must be restarted after writing this file.
- CI runner pushes to `mts.dgnr.us:5000` (Tailscale → `100.75.220.33` → svclb → zot pod).
- CI runner pushes to `registry.neko-piranha.ts.net` (Tailscale → `100.75.220.33` → svclb → zot pod).
### Resource Limits
@@ -111,14 +110,14 @@ kubectl config use-context buzz
# DB migration
kubectl delete job db-migrate -n buzz --ignore-not-found
kubectl create job db-migrate \
--image=mts.dgnr.us:5000/astral/buzz:frontend-$TAG \
--image=registry.neko-piranha.ts.net/astral/buzz:frontend-$TAG \
-n buzz \
--command -- bun dr push
kubectl wait --for=condition=complete job/db-migrate -n buzz --timeout=120s
# Roll app & backend
kubectl set image deployment/app app=mts.dgnr.us:5000/astral/buzz:frontend-$TAG -n buzz
kubectl set image deployment/backend backend=mts.dgnr.us:5000/astral/buzz:backend-$TAG -n buzz
kubectl set image deployment/app app=registry.neko-piranha.ts.net/astral/buzz:frontend-$TAG -n buzz
kubectl set image deployment/backend backend=registry.neko-piranha.ts.net/astral/buzz:backend-$TAG -n buzz
```
CI runner needs Gitea secret `KUBE_TOKEN` containing the `ci-deployer` SA token. Rotate via:
+1 -1
View File
@@ -36,7 +36,7 @@ spec:
terminationGracePeriodSeconds: 5
containers:
- name: app
image: mts.dgnr.us:5000/astral/buzz:frontend
image: registry.neko-piranha.ts.net/astral/buzz:frontend
ports:
- containerPort: 3000
protocol: TCP
+1 -1
View File
@@ -26,7 +26,7 @@ spec:
terminationGracePeriodSeconds: 5
containers:
- name: backend
image: mts.dgnr.us:5000/astral/buzz:backend
image: registry.neko-piranha.ts.net/astral/buzz:backend
env:
- name: NO_AUTH_CHECK
value: "true"
+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,
});
+5 -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(),
});
@@ -347,6 +349,7 @@ export const guides = pgTable("guides", {
//#region Donate
export const schDonate = pgSchema("donate");
export const paymentMethod = schDonate.enum("payment_method", ["tmn", "pp"]);
export const donations = schDonate.table("donations", {
id: text().primaryKey().$defaultFn(uuidv7),
@@ -359,6 +362,7 @@ export const donations = schDonate.table("donations", {
lastPing: timestamp("last_ping").default(new Date("01-01-2000")).notNull(),
sent: boolean().default(false).notNull(),
uid: text(),
method: paymentMethod().notNull().default("pp"),
});
//#endregion
+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: {
+2 -1
View File
@@ -50,6 +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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+14
View File
@@ -0,0 +1,14 @@
import { file, Glob, write } from "bun";
import { rm } from "node:fs/promises";
const g = new Glob("public/bgassets/*.png");
for await (const img of g.scan()) {
file(img)
.image()
.webp()
.buffer()
.then((b) => write(img.replace(".png", ".webp"), b))
.then(() => rm(img))
.finally(() => console.log(img, "processed."));
}
+3
View File
@@ -0,0 +1,3 @@
{
"bunVersion": "1.x"
}