Compare commits
@@ -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 &
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -48,6 +48,7 @@ export const sse = sseEndpointMap({
|
||||
active: {
|
||||
version: z.string(),
|
||||
live: z.custom<YoutubeLiveInfo>(),
|
||||
refresh: z.string().nullable(),
|
||||
},
|
||||
// Admin Live Log
|
||||
log: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 839 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 1016 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -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."));
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"bunVersion": "1.x"
|
||||
}
|
||||