diff --git a/.claude/skills/buzz-feature/SKILL.md b/.claude/skills/buzz-feature/SKILL.md index 244971a..cf2dbdc 100644 --- a/.claude/skills/buzz-feature/SKILL.md +++ b/.claude/skills/buzz-feature/SKILL.md @@ -183,7 +183,9 @@ export default function OGImage() { > Buzz Feature , - { ...size }, + { + ...size, + }, ); } ``` diff --git a/.env.example b/.env.example index e0ea53d..f12929b 100644 --- a/.env.example +++ b/.env.example @@ -38,4 +38,6 @@ SASTIFY_API_PRIVKEY=YOUR_SASTIFY_API_PRIVKEY DONATE_WIDGET_KEY=RANDOM_GENERATED # Environment -ENVIRONMENT=production \ No newline at end of file +ENVIRONMENT=production + +NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="openssl rand -base64 32" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe54e4a..c663bc3 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Configure kubectl run: | - kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true + kubectl config set-cluster buzz --server=https://astrxl-la3.neko-piranha.ts.net:6443 --insecure-skip-tls-verify=true kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }} kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz kubectl config use-context buzz @@ -57,7 +57,7 @@ jobs: - name: Configure kubectl run: | - kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true + kubectl config set-cluster buzz --server=https://astrxl-la3.neko-piranha.ts.net:6443 --insecure-skip-tls-verify=true kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }} kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz kubectl config use-context buzz @@ -98,7 +98,7 @@ jobs: - name: Configure kubectl run: | - kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true + kubectl config set-cluster buzz --server=https://astrxl-la3.neko-piranha.ts.net:6443 --insecure-skip-tls-verify=true kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }} kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz kubectl config use-context buzz @@ -114,7 +114,7 @@ jobs: -n buzz \ --dry-run=client -o json \ -- bun dr push | \ - jq '.spec.template.spec.containers[0].envFrom = [{"secretRef": {"name": "buzz-env"}}]' | \ + jq '.spec.template.spec.containers[0].envFrom = [{"secretRef": {"name": "buzz-env"}}] | .spec.template.spec.containers[0].imagePullPolicy = "Always"' | \ kubectl apply -f - # Roll app & backend diff --git a/Dockerfile b/Dockerfile index e2f08d1..52da55e 100755 --- a/Dockerfile +++ b/Dockerfile @@ -43,8 +43,9 @@ RUN mkdir -p .next/cache FROM oven/bun:1.3.14-alpine AS migration WORKDIR /home/container -RUN bun i uuidv7 drizzle-orm drizzle-kit +RUN bun i uuidv7 drizzle-orm drizzle-kit postgres +COPY --from=deps /home/container/package*.json ./ COPY --from=builder /home/container/drizzle.config.ts ./drizzle.config.ts COPY --from=builder /home/container/lib ./lib @@ -67,4 +68,4 @@ COPY --from=builder /home/container/.next/static ./.next/static COPY --from=builder /home/container/.version ./.version ENV HOSTNAME=0.0.0.0 -CMD ["bun", "server.js"] \ No newline at end of file +CMD ["bun", "server.js"] diff --git a/app/(ui)/admin/@modal/char/[id]/page.tsx b/app/(ui)/admin/@modal/char/[id]/page.tsx index 5625380..ef812cb 100755 --- a/app/(ui)/admin/@modal/char/[id]/page.tsx +++ b/app/(ui)/admin/@modal/char/[id]/page.tsx @@ -100,9 +100,9 @@ export default async function CharacterEditPage({ } return ( - + diff --git a/app/(ui)/admin/@modal/guide/[id]/page.tsx b/app/(ui)/admin/@modal/guide/[id]/page.tsx index db58e15..b20215d 100644 --- a/app/(ui)/admin/@modal/guide/[id]/page.tsx +++ b/app/(ui)/admin/@modal/guide/[id]/page.tsx @@ -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 }; } @@ -82,8 +84,8 @@ export default async function GuideEditPage({ } return ( - - + + diff --git a/app/(ui)/admin/@modal/guide/create/page.tsx b/app/(ui)/admin/@modal/guide/create/page.tsx index dbc2af1..c28f2c4 100644 --- a/app/(ui)/admin/@modal/guide/create/page.tsx +++ b/app/(ui)/admin/@modal/guide/create/page.tsx @@ -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 }; } diff --git a/app/(ui)/admin/@modal/tl/badge/[type]/[badge]/edit/page.tsx b/app/(ui)/admin/@modal/tl/badge/[type]/[badge]/edit/page.tsx new file mode 100644 index 0000000..0c543ac --- /dev/null +++ b/app/(ui)/admin/@modal/tl/badge/[type]/[badge]/edit/page.tsx @@ -0,0 +1,160 @@ +import { and, eq, isNull, or } from "drizzle-orm"; +import { SaveIcon, Trash2 } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { notFound, redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { FormAction, FormInput, FormProvider } from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistBadges, tierlistTypes } from "@/lib/db/schema"; + +export default async function TlBadgeEditPage({ + params, +}: { + params: Promise<{ type: string; badge: string }>; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { type: typeId, badge: badgeId } = await params; + const [[type], [badge]] = await Promise.all([ + db.select().from(tierlistTypes).where(eq(tierlistTypes.id, typeId)), + db + .select() + .from(tierlistBadges) + .where( + and( + eq(tierlistBadges.id, badgeId), + or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)), + ), + ), + ]); + if (!type) notFound(); + if (!badge) notFound(); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["name", "order", "scope"] as const) { + if (!form.get(field)) { + return { error: `Field "${field}" is required.` }; + } + } + + let data: Partial; + + try { + data = { + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + type: form.get("scope") === "global" ? null : typeId, + }; + await db + .update(tierlistBadges) + .set(data) + .where(eq(tierlistBadges.id, badgeId)); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || + err?.cause?.message || + "Failed to update badge.", + }; + } + + await actionLog(`Updated tierlist badge ${badgeId}`, data); + + revalidatePath(`/admin/tl/${typeId}`); + revalidatePath(`/tl/${typeId}`); + return { toast: "Badge saved successfully.", close: true }; + } + + async function deleteBadge() { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + await db.delete(tierlistBadges).where(eq(tierlistBadges.id, badgeId)); + + await actionLog(`Deleted tierlist badge ${badgeId}`, badge); + + revalidatePath(`/admin/tl/${typeId}`); + revalidatePath(`/tl/${typeId}`); + return { toast: "Badge deleted.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/badge/[type]/create/page.tsx b/app/(ui)/admin/@modal/tl/badge/[type]/create/page.tsx new file mode 100644 index 0000000..629ae1c --- /dev/null +++ b/app/(ui)/admin/@modal/tl/badge/[type]/create/page.tsx @@ -0,0 +1,130 @@ +import { eq, isNull, or, sql } from "drizzle-orm"; +import { PlusIcon } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { notFound, redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { FormAction, FormInput, FormProvider } from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistBadges, tierlistTypes } from "@/lib/db/schema"; + +export default async function TlBadgeCreatePage({ + params, +}: { + params: Promise<{ type: string }>; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { type: typeId } = await params; + const [[type], [{ maxOrder }]] = await Promise.all([ + db.select().from(tierlistTypes).where(eq(tierlistTypes.id, typeId)), + db + .select({ + maxOrder: sql`COALESCE(MAX(${tierlistBadges.order}), 0)`, + }) + .from(tierlistBadges) + .where(or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId))), + ]); + if (!type) notFound(); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["name", "order", "scope"] as const) { + if (!form.get(field)) { + return { error: `Field "${field}" is required.` }; + } + } + + let data: typeof tierlistBadges.$inferInsert; + + try { + data = { + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + type: form.get("scope") === "global" ? null : typeId, + }; + await db.insert(tierlistBadges).values(data); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || + err?.cause?.message || + "Failed to create badge.", + }; + } + + await actionLog(`Created tierlist badge for ${typeId}`, data); + + revalidatePath(`/admin/tl/${typeId}`); + revalidatePath(`/tl/${typeId}`); + return { toast: "Badge created successfully.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/layout/column/[id]/edit/page.tsx b/app/(ui)/admin/@modal/tl/layout/column/[id]/edit/page.tsx new file mode 100644 index 0000000..eeee553 --- /dev/null +++ b/app/(ui)/admin/@modal/tl/layout/column/[id]/edit/page.tsx @@ -0,0 +1,140 @@ +import { eq } from "drizzle-orm"; +import { SaveIcon, Trash2 } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { notFound, redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { + FormAction, + FormInput, + FormProvider, + FormRow, +} from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistColumns } from "@/lib/db/schema"; + +export default async function TlLayoutColumnEditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { id } = await params; + const [column] = await db + .select() + .from(tierlistColumns) + .where(eq(tierlistColumns.id, id)); + if (!column) notFound(); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["id", "name", "order"] as const) { + if (!form.get(field)) return { error: `Field "${field}" is required.` }; + } + + let data: typeof tierlistColumns.$inferInsert; + + try { + data = { + id: form.get("id") as string, + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + }; + await db + .update(tierlistColumns) + .set(data) + .where(eq(tierlistColumns.id, id)); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || + err?.cause?.message || + "Failed to update column.", + }; + } + + await actionLog(`Updated tierlist column ${id}`, data); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Column saved successfully.", close: true }; + } + + async function deleteColumn() { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + await db.delete(tierlistColumns).where(eq(tierlistColumns.id, id)); + await actionLog(`Deleted tierlist column ${id}`, column); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Column deleted.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/layout/column/create/page.tsx b/app/(ui)/admin/@modal/tl/layout/column/create/page.tsx new file mode 100644 index 0000000..65510c3 --- /dev/null +++ b/app/(ui)/admin/@modal/tl/layout/column/create/page.tsx @@ -0,0 +1,110 @@ +import { sql } from "drizzle-orm"; +import { PlusIcon } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { + FormAction, + FormInput, + FormProvider, + FormRow, +} from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistColumns } from "@/lib/db/schema"; + +export default async function TlLayoutColumnCreatePage() { + if (!(await adminCheck())) redirect("/login"); + + const [{ maxOrder }] = await db + .select({ + maxOrder: sql`COALESCE(MAX(${tierlistColumns.order}), 0)`, + }) + .from(tierlistColumns); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["id", "name", "order"] as const) { + if (!form.get(field)) return { error: `Field "${field}" is required.` }; + } + + let data: typeof tierlistColumns.$inferInsert; + + try { + data = { + id: form.get("id") as string, + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + }; + await db.insert(tierlistColumns).values(data); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || + err?.cause?.message || + "Failed to create column.", + }; + } + + await actionLog(`Created tierlist column ${data.id}`, data); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Column created successfully.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/layout/tier/[id]/edit/page.tsx b/app/(ui)/admin/@modal/tl/layout/tier/[id]/edit/page.tsx new file mode 100644 index 0000000..024c7be --- /dev/null +++ b/app/(ui)/admin/@modal/tl/layout/tier/[id]/edit/page.tsx @@ -0,0 +1,150 @@ +import { eq } from "drizzle-orm"; +import { SaveIcon, Trash2 } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { notFound, redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { + FormAction, + FormInput, + FormProvider, + FormRow, +} from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistTiers } from "@/lib/db/schema"; + +function parseBadges(value: FormDataEntryValue | null) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +export default async function TlLayoutTierEditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { id } = await params; + const [tier] = await db + .select() + .from(tierlistTiers) + .where(eq(tierlistTiers.id, id)); + if (!tier) notFound(); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["id", "name", "order"] as const) { + if (!form.get(field)) return { error: `Field "${field}" is required.` }; + } + + let data: typeof tierlistTiers.$inferInsert; + + try { + data = { + id: form.get("id") as string, + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + badges: parseBadges(form.get("badges")), + }; + await db.update(tierlistTiers).set(data).where(eq(tierlistTiers.id, id)); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || err?.cause?.message || "Failed to update tier.", + }; + } + + await actionLog(`Updated tierlist tier ${id}`, data); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Tier saved successfully.", close: true }; + } + + async function deleteTier() { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + await db.delete(tierlistTiers).where(eq(tierlistTiers.id, id)); + await actionLog(`Deleted tierlist tier ${id}`, tier); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Tier deleted.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/layout/tier/create/page.tsx b/app/(ui)/admin/@modal/tl/layout/tier/create/page.tsx new file mode 100644 index 0000000..92e0625 --- /dev/null +++ b/app/(ui)/admin/@modal/tl/layout/tier/create/page.tsx @@ -0,0 +1,121 @@ +import { sql } from "drizzle-orm"; +import { PlusIcon } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { + FormAction, + FormInput, + FormProvider, + FormRow, +} from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistTiers } from "@/lib/db/schema"; + +function parseBadges(value: FormDataEntryValue | null) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +export default async function TlLayoutTierCreatePage() { + if (!(await adminCheck())) redirect("/login"); + + const [{ maxOrder }] = await db + .select({ maxOrder: sql`COALESCE(MAX(${tierlistTiers.order}), 0)` }) + .from(tierlistTiers); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["id", "name", "order"] as const) { + if (!form.get(field)) return { error: `Field "${field}" is required.` }; + } + + let data: typeof tierlistTiers.$inferInsert; + + try { + data = { + id: form.get("id") as string, + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + badges: parseBadges(form.get("badges")), + }; + await db.insert(tierlistTiers).values(data); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || err?.cause?.message || "Failed to create tier.", + }; + } + + await actionLog(`Created tierlist tier ${data.id}`, data); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Tier created successfully.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/ver/[type]/[ver]/edit/page.tsx b/app/(ui)/admin/@modal/tl/ver/[type]/[ver]/edit/page.tsx index 36a43eb..c704d76 100755 --- a/app/(ui)/admin/@modal/tl/ver/[type]/[ver]/edit/page.tsx +++ b/app/(ui)/admin/@modal/tl/ver/[type]/[ver]/edit/page.tsx @@ -105,8 +105,8 @@ export default async function TlVersionEditPage({ } return ( - - + + diff --git a/app/(ui)/admin/@modal/tl/ver/[type]/edit/page.tsx b/app/(ui)/admin/@modal/tl/ver/[type]/edit/page.tsx index bea0dd8..06c25bb 100755 --- a/app/(ui)/admin/@modal/tl/ver/[type]/edit/page.tsx +++ b/app/(ui)/admin/@modal/tl/ver/[type]/edit/page.tsx @@ -84,8 +84,8 @@ export default async function TlTypeEditPage({ } return ( - - + + diff --git a/app/(ui)/admin/cdn/table.tsx b/app/(ui)/admin/cdn/table.tsx index 88e1063..80bea18 100755 --- a/app/(ui)/admin/cdn/table.tsx +++ b/app/(ui)/admin/cdn/table.tsx @@ -58,6 +58,7 @@ const columns: ColumnDef<{ ), enableSorting: false, enableHiding: false, + meta: { className: "w-6" }, }, { accessorKey: "name", diff --git a/app/(ui)/admin/client.tsx b/app/(ui)/admin/client.tsx index 2edcd9d..a5c3c08 100755 --- a/app/(ui)/admin/client.tsx +++ b/app/(ui)/admin/client.tsx @@ -14,78 +14,74 @@ export function VersionCreateDialogForm() { export function RandomWelcomeMessage() { const items = [ { - header: "ความลับแห่งแรงจันทราผัน", + header: "ไป Snezhnaya ได้ตั้งแต่ AR 18", message: - "แรงจันทราผัน (Kuuvahki) ใน Nod-Krai มีคุณสมบัติเหมือนแรงแม่เหล็ก ซึ่งเป็นกุญแจสำคัญในการแก้ปริศนาโบราณ", + "เมื่อจบ Archon Quest Prologue: Act III แล้ว จุด Teleport Waypoint ที่นำไปยัง Snezhnaya จะปลดล็อกให้อัตโนมัติ โดยไม่ต้องจบเควสต์ของภูมิภาคอื่นก่อน", }, { - header: "การล่าอันบ้าคลั่ง: Wild Hunt", + header: "ห้าพื้นที่ใหม่ในเวอร์ชัน 7.0", message: - "เมื่อเผชิญหน้ากับกองทัพ Wild Hunt การลด HP ให้เหลือ 0 เป็นเพียงจุดเริ่มต้นเท่านั้น คุณต้องลดค่า Max HP ของพวกมันให้เหลือ 0 ในขณะที่พวกมันติดสถานะ Grief-Stricken เพื่อกำจัดพวกมันอย่างถาวร ", + "Snezhnaya เปิดให้สำรวจ Volkodlak Tundra, White Birch Snowgrave, Everfrozen Earth, Flamefeather Valley และ Fellfrost Peak ในเวอร์ชัน 7.0", }, { - header: "ประกายจันทร์: Ascendant Gleam", + header: "อย่าลืมเก็บ Cryo Sigil", message: - "การจัดทีมที่มีตัวละครจาก Nod-Krai อย่างน้อย 2 ตัว จะเปิดใช้งาน Ascendant Gleam เพื่อให้ใช้ความสามรถตัวละครได้อย่างเต็มที่", + "Cryo Sigil ที่พบระหว่างสำรวจ Snezhnaya ใช้สร้างและเพิ่มเลเวล Meeting Points เพื่อรับเสบียงและรางวัลเพิ่มเติม", }, { - header: "มรดกแห่งดวงจันทร์ทั้งสาม", + header: "Meeting Points สามแห่ง", message: - "Nod-Krai เคยถูกเรียกว่า Nephilheim และถูกเชื่อว่าเป็นเศษเสี้ยวของดวงจันทร์ดวงที่สี่ซึ่งแตกสลายก่อนจะก่อตัวเสร็จสิ้น ทำให้ดินแดนแห่งนี้มีความเชื่อมโยงกับพลังจันทราอย่างลึกซึ้ง", + "Meeting Points ในเวอร์ชัน 7.0 ได้แก่ Tidesong Cavern, Huntsman's Cabin และ The Korolevskiy Theater ปลดล็อกได้จากเนื้อเรื่อง Act I", }, { - header: "คำสาบานของผู้เฝ้าประภาคาร", + header: "ปลดล็อก Daily Commission", message: - "กลุ่ม Lightkeepers ยึดถือคติ 'ใช้เลือดและกระดูกเป็นเชื้อเพลิง' พวกเขาเฝ้าประภาคารที่ Piramida มานานกว่า 500 ปีเพื่อไม่ให้ความมืดจาก Abyss ล่มสลายดินแดนของมนุษย์", + "ต้องเล่น Archon Quest Chapter VII: Act I จนถึงช่วง Great Deeds on the Tundra ก่อน จึงจะเปิด Daily Commission ของ Snezhnaya ได้", }, { - header: "อาวุธชำระล้าง Terpikeraunas", + header: "Traveler ใช้ธาตุ Cryo ได้แล้ว", message: - "Arrow of Terpikeraunas เป็นโบราณวัตถุจาก Hyperborea ที่สามารถเปลี่ยนพลังงานจาก Light Realm ให้เป็นเปลวไฟที่ใช้บริสุทธิ์พลังงานจาก Void Realm (Abyss) ได้", + "นำ Traveler ไปสั่นพ้องกับ Statue of The Seven ใน Snezhnaya เพื่อปลดล็อกธาตุ Cryo ซึ่งเล่นได้ทั้งแบบลงสนามและสลับมาทำดาเมจนอกสนาม", }, { - header: "วงล้อจันทรา: สิ่งมาก่อนวิชั่น", + header: "Stellar Conduct", message: - "ใน Nod-Krai ผู้คนอาจได้รับ 'Moon Wheel' (กงล้อจันทรา) ซึ่งเป็นอุปกรณ์ที่ช่วยให้สามารถควบคุมแรงจันทราผันเพื่อปกป้องตนเองในถิ่นทุรกันดาร", + "เมื่อมีตัวละครที่เปิดใช้ Stellar Glimmer อยู่ในทีม ปฏิกิริยา Electro + Cryo จะเปลี่ยนจาก Superconduct เป็น Stellar Conduct และสร้าง Polestar Field", }, { - header: "การรุกรานของ Rerir", + header: "Stellar Swirl", message: - "Rerir หนึ่งในห้ามหาบาปแห่ง Khaenri'ah เป็นผู้อยู่เบื้องหลังการโจมตีของ Wild Hunt เขามีเป้าหมายในการรวบรวมชิ้นส่วนของดวงจันทร์เพื่อฟื้นฟูร่างกายที่แตกสลายของตนเอง ", + "Stellar Swirl เกิดจาก Anemo + Cryo เมื่อมีตัวเปิดใช้ปฏิกิริยาในทีม โดยจะสร้าง Stellar Vortex ที่ระเบิดเป็นความเสียหาย Cryo หลังเวลาหนึ่ง", }, { - header: "เมืองสีทองที่ล่มสลาย Hyperborea", + header: "Odette ตัวช่วยปฏิกิริยาใหม่", message: - "Hyperborea ตั้งอยู่ทางตอนเหนือสุดของ Nod-Krai เมืองนี้ล่มสลายลงหลังโดน Celestial Nail ปักใส่ ปัจจุบันกลายเป็นซากปรักหักพังที่เต็มไปด้วยความลับของภาพสะท้อนแห่งสวรรค์ ", + "Odette เป็นตัวละคร Cryo ดาบ 5 ดาวที่เปิดใช้ Stellar Conduct และ Stellar Swirl พร้อมสร้างความเสียหาย Stellar Glimmer จากนอกสนาม", }, { - header: "นวัตกรรมจาก Clink-Clank Krumkake", + header: "รับ Alyosha ฟรีจากเนื้อเรื่อง", message: - "ร้านงานฝีมือ Krumkake เป็นศูนย์กลางของการประดิษฐ์ใน Nod-Krai มีการผสมผสานเทคโนโลยีเฟืองจาก Fontaine และพลังงานคูวากิเพื่อสร้างอุปกรณ์สำรวจที่ไม่มีใครเหมือน ", + "Alyosha เป็นตัวละคร Electro หอก 4 ดาวที่รับได้จาก Archon Quest Chapter VII: Act I เขาช่วยฮีลและสร้างความเสียหาย Electro ต่อเนื่องได้", }, { - header: "ความเชื่อของลูกหลานจันทราน้ำค้างแข็ง", + header: "ดาบ 5 ดาวฟรีสำหรับ Traveler", message: - "ลูกหลานจันทราน้ำค้างแข็งได้เคารพศรัทธาต่อ 'เทพจันทรา' และได้ละทิ้งอารยธรรมทองคำยุคเก่า", - }, - // { - // "header": "สายใยครอบครัวในดินแดน Elysium", - // "message": "Nod-Krai ได้รับฉายาว่า Elysium เนื่องจากเป็นที่รวมตัวของคนนอกกฎหมายและนักผจญภัยจากทั่ว Teyvat ธีมหลักของตัวละครที่นี่จึงมักเป็นเรื่องของ 'ครอบครัวที่เลือกเอง' (Found Family) " - // }, - { - header: "นกไนติงเกลของ Solovei", - message: - "ตราสัญลักษณ์ของผู้เฝ้าประภาคารคือ นกไนติงเกลคาบไส้ตะเกียงที่ลุกไหม้ ซึ่งมาจากสัญลักษณ์ประจำตระกูลของ Solovei ผู้ก่อตั้งองค์กรและ Starshyna คนแรก ", + "Exaiphanes Blade รับได้จาก Archon Quest Everwinter Without Mercy และในเวอร์ชัน 7.0 สามารถขัดเกลาถึง R3 ผ่าน Statue of The Seven ใน Snezhnaya", }, { - header: "จันทราทั้ง 3", + header: "อาร์ติแฟกต์จาก Inverted Glacier", message: - "ในอดีตของ Teyvat เคยมีดวงจันทร์ทั้ง 3 ได้แก่ จันทราน้ำค้างแข็ง จันทราสีรุ้ง และจันทรานิรันดร์", + "Domain Inverted Glacier ใน Everfrozen Earth มีเซ็ต Scarlet Proof และ Heart of the Furnace ซึ่งออกแบบมาสำหรับทีม Stellar Glimmer", }, { - header: "Genshin impact", + header: "อาวุธคราฟต์ใหม่ครบห้าประเภท", message: - "Action RPG แบบ Open World สไตล์อนิเมะ ที่โดดเด่นด้วยระบบการต่อสู้โดยใช้พลังธาตุและการสำรวจโลกกว้างที่อิสระ", + "เวอร์ชัน 7.0 เพิ่มสูตรอาวุธคราฟต์ 4 ดาวของ Snezhnaya สำหรับ Sword, Claymore, Polearm, Catalyst และ Bow โดยเน้นทีมปฏิกิริยา Stellar Glimmer", + }, + { + header: "แวะสมาคมตกปลา Snezhnaya", + message: + "Snezhnaya มี Fishing Association และจุดตกปลาใหม่ อย่าลืมสำรวจแหล่งน้ำเพื่อจับปลาและนำไปแลกรางวัลประจำภูมิภาค", }, ]; diff --git a/app/(ui)/admin/guide/api.ts b/app/(ui)/admin/guide/api.ts index 96ee826..9210517 100644 --- a/app/(ui)/admin/guide/api.ts +++ b/app/(ui)/admin/guide/api.ts @@ -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"); } diff --git a/app/(ui)/admin/layout.tsx b/app/(ui)/admin/layout.tsx index bdc4679..ab167a7 100755 --- a/app/(ui)/admin/layout.tsx +++ b/app/(ui)/admin/layout.tsx @@ -1,22 +1,23 @@ -import { desc, eq, sql } from "drizzle-orm"; +import { count, desc, eq, sql } from "drizzle-orm"; import { ArrowLeftRight, - Badge, BadgeDollarSign, BitcoinIcon, + Blocks, BookUser, - Columns3Cog, + Columns3CogIcon, Compass, Computer, Database, ExternalLink, - GitGraph, Grid3X3, IdCard, + Layout, ListTree, MoreHorizontal, Package, PlusCircle, + PlusIcon, ScrollText, Settings, SquareUserRound, @@ -54,6 +55,7 @@ import { SidebarHeader, SidebarInset, SidebarMenu, + SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarProvider, @@ -92,6 +94,18 @@ export default async function AdminLayout({ .innerJoin(tierlistTypes, eq(tierlistTypes.id, tierlistVersions.type)) .catch(() => []); + const tlTypes = db + .select({ + id: tierlistTypes.id, + name: tierlistTypes.name, + count: count(tierlistVersions.id), + }) + .from(tierlistTypes) + .orderBy(tierlistTypes.id) + .leftJoin(tierlistVersions, eq(tierlistTypes.id, tierlistVersions.type)) + .groupBy(tierlistTypes.id) + .catch(() => []); + return ( @@ -129,25 +143,28 @@ export default async function AdminLayout({ - Tierlist [WIP] + Tierlist + + + + + + + - - - Versions + + Layout Editor + + + - - - Badges - - - - - - Layout + + + Custom TL @@ -257,6 +274,7 @@ export default async function AdminLayout({ @@ -310,7 +328,7 @@ async function HealthStatus() { {Object.values(health).some((x) => !x) - ? `${{ database: "ฐานข้อมูล", enka: "Enka ", amber: "Amber ", red: "SSE " }[Object.entries(health).find(([, v]) => !v)![0]]}มีปัญหา` + ? `${((h: string) => ({ database: "ฐานข้อมูล", enka: "Enka ", amber: "Amber ", red: "SSE " })[h] ?? `${h} `)(Object.entries(health).find(([, v]) => !v)![0])}มีปัญหา` : "ปกติทุกอย่าง"} @@ -322,6 +340,7 @@ async function HealthStatus() { async function AdminNavbarLoader({ adminShortcuts, + tierlistTypes, tierlistVersions, }: { adminShortcuts: Promise< @@ -330,6 +349,13 @@ async function AdminNavbarLoader({ url: string; }[] >; + tierlistTypes: Promise< + { + id: string; + name: string; + count: number; + }[] + >; tierlistVersions: Promise< { id: string; @@ -340,11 +366,29 @@ async function AdminNavbarLoader({ return ( ); } +async function TierlistList({ + types: prom, +}: { + types: Promise<{ id: string; name: string; count: number }[]>; +}) { + const types = await prom; + return types.map((t) => ( + + + + {t.name} + {t.count} + + + )); +} + async function VersionsList({ versions: prom, }: { diff --git a/app/(ui)/admin/navbar.tsx b/app/(ui)/admin/navbar.tsx index 81a69e6..f50ffd6 100644 --- a/app/(ui)/admin/navbar.tsx +++ b/app/(ui)/admin/navbar.tsx @@ -2,7 +2,6 @@ import { AlignJustify, - Badge, BadgeDollarSign, BitcoinIcon, ChevronRight, @@ -10,10 +9,10 @@ import { Compass, ExternalLink, Form, - GitGraph, Grid3x3, Home, IdCard, + Layout, List, Package, Plus, @@ -158,12 +157,18 @@ function DropdownMenu({ export function AdminNavbar({ adminShortcuts, + tierlistTypes, tierlistVersions, }: { adminShortcuts: { name: string; url: string; }[]; + tierlistTypes: { + id: string; + name: string; + count: number; + }[]; tierlistVersions: { id: string; name: string; @@ -181,9 +186,12 @@ export function AdminNavbar({ name: "Tierlist", icon: Table, sub: [ - { name: "Versions", icon: GitGraph, href: "/admin/tl/ver" }, - { name: "Badges", icon: Badge, href: "/admin/tl/badges" }, { name: "Layout", icon: Columns3Cog, href: "/admin/tl/layout" }, + ...tierlistTypes.map((type) => ({ + name: type.name, + icon: Layout, + href: `/admin/tl/${type.id}`, + })), ], }, { name: "Guide", icon: Compass, href: "/admin/guide" }, diff --git a/app/(ui)/admin/settings/api.ts b/app/(ui)/admin/settings/api.ts index 877b012..3f7f555 100755 --- a/app/(ui)/admin/settings/api.ts +++ b/app/(ui)/admin/settings/api.ts @@ -1,21 +1,24 @@ "use server"; -import { exec } from "node:child_process"; -import { env } from "node:process"; -import { promisify } from "node:util"; 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"; +import { syncAmber as syncAmberRaw } from "@/util/sync"; +import type { ToastType } from "@/components/fake-toast"; -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 +30,46 @@ export async function toggleEnka(state: boolean) { } export async function syncAmber() { - const res = await promisify(exec)("bun util/sync 2>&1", { - env: { ...env, NO_AUTH_CHECK: "1" }, + if (!(await adminCheck())) throw "Unauthorized"; + await syncAmberRaw(); + await actionLog("Triggered an Amber sync", { + result: "OK(log is wip, check `kubectl logs -fn buzz deployments/app`)", }); - await actionLog("Triggered an Amber sync", { result: res.stdout }); - return res.stdout; + return "OK(log is wip, check `kubectl logs -fn buzz deployments/app`)"; +} + +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 }); +} + +export async function broadcastMessage( + prefix: string, + value: string, + type: ToastType, +) { + "use server"; + + sse.active.pub("announcement", { + severity: type, + message: value, + prefix, + }); + + await actionLog("Broadcasted message", { prefix, value, type }); } diff --git a/app/(ui)/admin/settings/broadcaster.tsx b/app/(ui)/admin/settings/broadcaster.tsx new file mode 100644 index 0000000..7942f90 --- /dev/null +++ b/app/(ui)/admin/settings/broadcaster.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Send } from "lucide-react"; +import { useState } from "react"; +import { FakeToast, type ToastType } from "@/components/fake-toast"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { broadcastMessage } from "./api"; +import { ActionButton } from "@/components/action-button"; + +const toastTypes: ToastType[] = ["warning", "error", "success", "info"]; + +export function Broadcaster() { + const [expanded, setExpanded] = useState(false); + const [prefix, setPrefix] = useState("/"); + const [prefixFocused, setPrefixFocused] = useState(false); + const [value, setValue] = useState(""); + const [type, setType] = useState("warning"); + + function cycleType() { + setType((currentType) => { + const currentIndex = toastTypes.indexOf(currentType); + return toastTypes[(currentIndex + 1) % toastTypes.length]; + }); + } + + return ( +
+ + setExpanded(true)} + onBlur={(ev) => setValue(ev.target.textContent)} + contentEditable + data-placeholder="Broadcast message..." + className="w-full flex-1" + > + +
+ setPrefix(event.target.value)} + onFocus={() => setPrefixFocused(true)} + onBlur={() => setPrefixFocused(false)} + className="backdrop-blur-xs transition-[width] focus:w-48" + style={{ + width: prefixFocused + ? undefined + : `calc(24px + ${Math.max(prefix.length, 1)}ch)`, + }} + /> + { + await broadcastMessage(prefix, value, type); + setExpanded(false); + }} + > + + +
+
+ ); +} diff --git a/app/(ui)/admin/settings/page.tsx b/app/(ui)/admin/settings/page.tsx index 562a596..0956a5e 100755 --- a/app/(ui)/admin/settings/page.tsx +++ b/app/(ui)/admin/settings/page.tsx @@ -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 (
@@ -22,7 +22,11 @@ export default async function () { บัญชี - + @@ -49,3 +53,5 @@ export function Section({ ); } + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/settings/services.tsx b/app/(ui)/admin/settings/services.tsx index bb80953..02bd51a 100755 --- a/app/(ui)/admin/settings/services.tsx +++ b/app/(ui)/admin/settings/services.tsx @@ -1,48 +1,103 @@ "use client"; -import { FolderSync } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Bomb, FolderSync } from "lucide-react"; +import { useState } from "react"; +import { ActionButton } from "@/components/action-button"; import { CdnChooser } from "@/components/chooser"; -import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { shared } from "@/lib/comms"; -import { syncAmber, toggleEnka } from "./api"; +import { + forceRefresh, + syncAmber, + toggleDonatePaymentMethod, + toggleEnka, +} from "./api"; +import { Broadcaster } from "./broadcaster"; import { Section } from "./page"; 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(); + const [enka, setEnka] = useState(enkaInitial); + const [donPp, setDonPp] = useState(initialDonPp); + const [donTmn, setDonTmn] = useState(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 (
- + ( + setEnka(v), + toggleEnka(v).then(forceRefresh.bind(null, "/artifact")) + )} + /> เปิดใช้การดึงตัวละครใน Artifact
- +
+ + + Sync Amber + + + + FORCE RELOAD + +
+
+ setPathToReload(v.target.value)} + /> + + + Reload + +
+ Donate Payment Method +
+ ( + setDonPp(v), + toggleDonatePaymentMethod("donatePromptpay", v).then( + forceRefresh.bind(null, "/donate"), + ) + )} + /> + Promptpay +
+
+ ( + setDonTmn(v), + toggleDonatePaymentMethod("donateTruemoney", v).then( + forceRefresh.bind(null, "/donate"), + ) + )} + /> + Truemoney +
{debug && (
@@ -54,6 +109,7 @@ export function SettingsServicesSection({ {syncResult && (
{syncResult}
)} +
); diff --git a/app/(ui)/admin/tl/[ver]/actions.ts b/app/(ui)/admin/tl/[ver]/actions.ts new file mode 100644 index 0000000..c0355ff --- /dev/null +++ b/app/(ui)/admin/tl/[ver]/actions.ts @@ -0,0 +1,82 @@ +"use server"; + +import { and, eq, isNull, or } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistBadges } from "@/lib/db/schema"; +import { midpointOrder, normalizedOrders } from "../components/order"; + +function sameIds(left: string[], right: string[]) { + if (left.length !== right.length) return false; + + const a = [...left].sort(); + const b = [...right].sort(); + return a.every((id, index) => id === b[index]); +} + +export async function reorderBadges(args: { + typeId: string; + activeId: string; + ids: string[]; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { typeId, activeId, ids } = args; + + await db.transaction(async (tx) => { + const badges = await tx + .select() + .from(tierlistBadges) + .where(or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId))) + .orderBy(tierlistBadges.order, tierlistBadges.id); + + const visibleIds = badges.map((badge) => badge.id); + if (!sameIds(visibleIds, ids)) { + throw new Error("Badge order is out of date. Refresh and try again."); + } + + const index = ids.indexOf(activeId); + if (index === -1) throw new Error("Moved badge is missing."); + + const prev = + index > 0 + ? badges.find((badge) => badge.id === ids[index - 1]) + : undefined; + const next = + index < ids.length - 1 + ? badges.find((badge) => badge.id === ids[index + 1]) + : undefined; + const nextOrder = midpointOrder(prev?.order, next?.order); + + if (nextOrder == null) { + for (const item of normalizedOrders(ids)) { + await tx + .update(tierlistBadges) + .set({ order: item.order }) + .where(eq(tierlistBadges.id, item.id)); + } + } else { + await tx + .update(tierlistBadges) + .set({ order: nextOrder }) + .where( + and( + eq(tierlistBadges.id, activeId), + or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)), + ), + ); + } + }); + + await actionLog(`Reordered tierlist badges for ${typeId}`, { + typeId, + activeId, + ids, + }); + + revalidatePath(`/admin/tl/${typeId}`); + revalidatePath(`/tl/${typeId}`); +} diff --git a/app/(ui)/admin/tl/[ver]/badge-section.tsx b/app/(ui)/admin/tl/[ver]/badge-section.tsx new file mode 100644 index 0000000..978f649 --- /dev/null +++ b/app/(ui)/admin/tl/[ver]/badge-section.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { GripVertical, PencilLine, PlusIcon } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import type { ComponentProps } from "react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { SimpleTooltip } from "@/components/tooltip"; +import { Button } from "@/components/ui/button"; +import type { tierlistBadges } from "@/lib/db/schema"; +import { cn } from "@/lib/utils"; +import { reorderFlatItems } from "../components/order"; +import { SortableGrid, SortableGridItem } from "../components/sortable-grid"; +import { reorderBadges } from "./actions"; + +export function BadgeSection({ + typeId, + typeName, + badges: initialBadges, +}: { + typeId: string; + typeName: string; + badges: (typeof tierlistBadges.$inferSelect)[]; +}) { + const [badges, setBadges] = useState(initialBadges); + const [pending, startTransition] = useTransition(); + const badgeSize = 48; + + function commitReorder(activeId: string, overId: string) { + const next = reorderFlatItems(badges, activeId, overId); + if (next === badges) return; + + const previous = badges; + setBadges(next); + + startTransition(() => { + reorderBadges({ + typeId, + activeId, + ids: next.map((badge) => badge.id), + }).catch((error) => { + console.error(error); + setBadges(previous); + toast.error( + error instanceof Error ? error.message : "Failed to reorder badges.", + ); + }); + }); + } + + return ( +
+
+ + Badges + + (blue = global, green = {typeName} local) + + + +
+ badge.id)} + onReorder={commitReorder} + disabled={pending} + overlay={(id) => { + const badge = badges.find((entry) => entry.id === id); + return badge ? ( + + ) : null; + }} + > +
+ {badges.map((badge) => ( + + {({ attributes, listeners, setNodeRef, style, isDragging }) => ( +
+ +
+ )} +
+ ))} +
+
+
+ ); +} + +function BadgeTile({ + badge, + typeId, + badgeSize, + dragHandle, + overlay = false, +}: { + badge: typeof tierlistBadges.$inferSelect; + typeId: string; + badgeSize: number; + dragHandle?: ComponentProps<"button">; + overlay?: boolean; +}) { + const borderClass = + badge.type === null ? "border-blue-500" : "border-emerald-400"; + + return ( +
+ + {!overlay && ( +
+ + + +
+ )} + {badge.image ? ( + {badge.name} + ) : ( +
+ {badge.name} +
+ )} +
+ ); +} diff --git a/app/(ui)/admin/tl/[ver]/page.tsx b/app/(ui)/admin/tl/[ver]/page.tsx new file mode 100755 index 0000000..5ff4dda --- /dev/null +++ b/app/(ui)/admin/tl/[ver]/page.tsx @@ -0,0 +1,137 @@ +import { desc, eq } from "drizzle-orm"; +import { ExternalLink, Lock, PencilLine, PlusIcon } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { SimpleTooltip } from "@/components/tooltip"; +import { Button } from "@/components/ui/button"; +import { Kbd } from "@/components/ui/kbd"; +import { db } from "@/lib/db"; +import { + tierlistBadges, + tierlistTypes, + tierlistVersions, +} from "@/lib/db/schema"; +import { syncTierlistSnapshots } from "@/lib/tierlist"; +import { cn } from "@/lib/utils"; +import { BadgeSection } from "./badge-section"; + +export default async function TierlistManagerPage({ + params, +}: PageProps<"/admin/tl/[ver]">) { + const { ver } = await params; + await syncTierlistSnapshots(ver); + const [[type], versionsList, badges] = await Promise.all([ + db.select().from(tierlistTypes).where(eq(tierlistTypes.id, ver)), + db + .select() + .from(tierlistVersions) + .orderBy(desc(tierlistVersions.order)) + .where(eq(tierlistVersions.type, ver)), + db + .select() + .from(tierlistBadges) + .orderBy(tierlistBadges.order, tierlistBadges.id), + ]); + const t = { + ...type, + versions: versionsList, + }; + return ( +
+
+
+
+ {t.name} + + {t.id} + {t.mode} + +
+
+ + + + + + +
+
+
+ {t.versions.map((e) => ( +
+ {e.image ? ( + <> + {e.name} +
+ {e.name} {e.id} +
+ + ) : ( + e.name + )} +
+ + + + + + +
+ {e.snapshot && ( +
+ + + +
+ )} +
+ ))} +
+
+
+
+
+ (badge.type ?? ver) === ver)} + /> +
+
+
+
+ ); +} diff --git a/app/(ui)/admin/tl/components/order.ts b/app/(ui)/admin/tl/components/order.ts new file mode 100644 index 0000000..fcf3dea --- /dev/null +++ b/app/(ui)/admin/tl/components/order.ts @@ -0,0 +1,36 @@ +export const ORDER_STEP = 10; + +export function reorderFlatItems( + items: T[], + activeId: string, + overId: string, +) { + if (activeId === overId) return items; + + const oldIndex = items.findIndex((item) => item.id === activeId); + const newIndex = items.findIndex((item) => item.id === overId); + if (oldIndex === -1 || newIndex === -1) return items; + + return arrayMove(items, oldIndex, newIndex); +} + +export function midpointOrder(prev?: number, next?: number) { + if (prev == null && next == null) return ORDER_STEP; + if (prev == null) return next! - ORDER_STEP; + if (next == null) return prev + ORDER_STEP; + + const middle = Math.floor((prev + next) / 2); + if (middle === prev || middle === next) return null; + return middle; +} + +export function normalizedOrders(ids: string[]) { + return ids.map((id, index) => ({ + id, + order: (index + 1) * ORDER_STEP, + })); +} + +function arrayMove(arr: T[], oldIndex: number, newIndex: number) { + return arr.toSpliced(oldIndex, 1).toSpliced(newIndex, 0, arr[oldIndex]); +} diff --git a/app/(ui)/admin/tl/components/sortable-grid.tsx b/app/(ui)/admin/tl/components/sortable-grid.tsx new file mode 100644 index 0000000..f430b06 --- /dev/null +++ b/app/(ui)/admin/tl/components/sortable-grid.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { + closestCenter, + DndContext, + type DragEndEvent, + type DraggableAttributes, + DragOverlay, +} from "@dnd-kit/core"; +import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"; +import { + rectSortingStrategy, + SortableContext, + useSortable, +} from "@dnd-kit/sortable"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +export function SortableGrid({ + items, + onReorder, + children, + overlay, + disabled = false, +}: { + items: string[]; + onReorder: (activeId: string, overId: string) => void; + children?: ReactNode; + overlay?: (id: string) => ReactNode; + disabled?: boolean; +}) { + const [dragging, setDragging] = useState(null); + + function handleDragEnd(event: DragEndEvent) { + setDragging(null); + if (disabled || !event.over) return; + onReorder(event.active.id as string, event.over.id as string); + } + + return ( + setDragging(event.active.id as string)} + onDragCancel={() => setDragging(null)} + onDragEnd={handleDragEnd} + > + + {children} + + {dragging && overlay?.(dragging)} + + ); +} + +export function SortableGridItem({ + id, + disabled = false, + children, +}: { + id: string; + disabled?: boolean; + children: (props: { + listeners: SyntheticListenerMap | undefined; + attributes: DraggableAttributes | undefined; + setNodeRef: (node: HTMLElement | null) => void; + style: React.CSSProperties | undefined; + isDragging: boolean; + }) => ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }); + + return children({ + listeners, + attributes, + setNodeRef, + isDragging, + style: { + transform: transform + ? `translate3d(${transform.x}px, ${transform.y}px, 0) scaleX(${transform.scaleX}) scaleY(${transform.scaleY})` + : undefined, + transition, + opacity: isDragging ? 0.5 : 1, + }, + }); +} diff --git a/app/(ui)/admin/tl/layout/actions.ts b/app/(ui)/admin/tl/layout/actions.ts new file mode 100644 index 0000000..315e378 --- /dev/null +++ b/app/(ui)/admin/tl/layout/actions.ts @@ -0,0 +1,76 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistColumns, tierlistTiers } from "@/lib/db/schema"; +import { midpointOrder, normalizedOrders } from "../components/order"; + +function sameIds(left: string[], right: string[]) { + if (left.length !== right.length) return false; + + const a = [...left].sort(); + const b = [...right].sort(); + return a.every((id, index) => id === b[index]); +} + +async function reorderTable( + ids: string[], + activeId: string, + type: "tiers" | "columns", +) { + const table = type === "tiers" ? tierlistTiers : tierlistColumns; + + await db.transaction(async (tx) => { + const items = await tx.select().from(table).orderBy(table.order, table.id); + const visibleIds = items.map((item) => item.id); + if (!sameIds(visibleIds, ids)) { + throw new Error("Layout order is out of date. Refresh and try again."); + } + + const index = ids.indexOf(activeId); + if (index === -1) throw new Error("Moved item is missing."); + + const prev = + index > 0 ? items.find((item) => item.id === ids[index - 1]) : undefined; + const next = + index < ids.length - 1 + ? items.find((item) => item.id === ids[index + 1]) + : undefined; + const nextOrder = midpointOrder(prev?.order, next?.order); + + if (nextOrder == null) { + for (const item of normalizedOrders(ids)) { + await tx + .update(table) + .set({ order: item.order }) + .where(eq(table.id, item.id)); + } + } else { + await tx + .update(table) + .set({ order: nextOrder }) + .where(eq(table.id, activeId)); + } + }); + + await actionLog(`Reordered tierlist ${type}`, { activeId, ids }); + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); +} + +export async function reorderTiers(args: { activeId: string; ids: string[] }) { + if (!(await adminCheck())) redirect("/login"); + return reorderTable(args.ids, args.activeId, "tiers"); +} + +export async function reorderColumns(args: { + activeId: string; + ids: string[]; +}) { + if (!(await adminCheck())) redirect("/login"); + return reorderTable(args.ids, args.activeId, "columns"); +} diff --git a/app/(ui)/admin/tl/layout/client.tsx b/app/(ui)/admin/tl/layout/client.tsx new file mode 100644 index 0000000..b63b8ba --- /dev/null +++ b/app/(ui)/admin/tl/layout/client.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { GripVertical, PencilLine, PlusIcon } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import type { ComponentProps } from "react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { SimpleTooltip } from "@/components/tooltip"; +import { Button } from "@/components/ui/button"; +import { Kbd, KbdGroup } from "@/components/ui/kbd"; +import type { tierlistColumns, tierlistTiers } from "@/lib/db/schema"; +import { cn } from "@/lib/utils"; +import { reorderFlatItems } from "../components/order"; +import { SortableGrid, SortableGridItem } from "../components/sortable-grid"; +import { reorderColumns, reorderTiers } from "./actions"; + +type Tier = typeof tierlistTiers.$inferSelect; +type Column = typeof tierlistColumns.$inferSelect; + +export function LayoutEditor({ + tiers: initialTiers, + columns: initialColumns, +}: { + tiers: Tier[]; + columns: Column[]; +}) { + const [tiers, setTiers] = useState(initialTiers); + const [columns, setColumns] = useState(initialColumns); + const [pending, startTransition] = useTransition(); + + function commitReorder( + items: T[], + setItems: (items: T[]) => void, + activeId: string, + overId: string, + persist: (args: { activeId: string; ids: string[] }) => Promise, + label: string, + ) { + const next = reorderFlatItems(items, activeId, overId); + if (next === items) return; + + const previous = items; + setItems(next); + + startTransition(() => { + persist({ activeId, ids: next.map((item) => item.id) }).catch((error) => { + console.error(error); + setItems(previous); + toast.error( + error instanceof Error + ? error.message + : `Failed to reorder ${label}.`, + ); + }); + }); + } + + return ( +
+
+
+
+ + column.id)} + onReorder={(activeId, overId) => + commitReorder( + columns, + setColumns, + activeId, + overId, + reorderColumns, + "columns", + ) + } + disabled={pending} + overlay={(id) => { + const column = columns.find((entry) => entry.id === id); + return column ? ( + + ) : null; + }} + > + {columns.length ? ( + columns.map((column) => ( + + {({ + attributes, + listeners, + setNodeRef, + style, + isDragging, + }) => ( +
+ +
+ )} +
+ )) + ) : ( +
+ Add your first column +
+ )} +
+ + + + {tiers.length ? ( + tier.id)} + onReorder={(activeId, overId) => + commitReorder( + tiers, + setTiers, + activeId, + overId, + reorderTiers, + "tiers", + ) + } + disabled={pending} + overlay={(id) => { + const tier = tiers.find((entry) => entry.id === id); + return tier ? ( + + ) : null; + }} + > + {tiers.map((tier) => ( + column.id) + : ["empty"] + } + pending={pending} + /> + ))} + + ) : ( + <> +
+ Add your first tier +
+ {(columns.length + ? columns.map((column) => column.id) + : ["empty"] + ).map((id) => ( + + ))} + + )} + + + {(columns.length + ? columns.map((column) => column.id) + : ["empty"] + ).map((id) => ( + + ))} + +
+
+
+ ); +} + +function TierRow({ + tier, + columnIds, + pending, +}: { + tier: Tier; + columnIds: string[]; + pending: boolean; +}) { + return ( + <> + + {({ attributes, listeners, setNodeRef, style, isDragging }) => ( +
+ +
+ )} +
+ {columnIds.map((columnId) => ( + + ))} + + + ); +} + +function BlankCell() { + return
; +} + +function ThinCell() { + return
; +} + +function AddSlot({ href, label }: { href: string; label: string }) { + return ( + + + + + + ); +} + +function LayoutNode({ + item, + href, + dragHandle, + orientation, + badges, + overlay = false, +}: { + item: { id: string; name: string; image: string | null }; + href?: string; + dragHandle?: ComponentProps<"button">; + orientation: "tier" | "column"; + badges?: string[] | null; + overlay?: boolean; +}) { + return ( +
+ + {href && !overlay && ( +
+ + + +
+ )} +
+ {item.image ? ( + {item.name} + ) : ( + {item.name[0] ?? "?"} + )} +
+
+
+
{item.name}
+ {item.id} +
+ {orientation === "tier" && ( +
+ {badges?.length ? ( + + {badges.map((badge) => ( + {badge} + ))} + + ) : ( + No badges + )} +
+ )} +
+
+ ); +} diff --git a/app/(ui)/admin/tl/layout/page.tsx b/app/(ui)/admin/tl/layout/page.tsx new file mode 100644 index 0000000..a058f28 --- /dev/null +++ b/app/(ui)/admin/tl/layout/page.tsx @@ -0,0 +1,24 @@ +import { redirect } from "next/navigation"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistColumns, tierlistTiers } from "@/lib/db/schema"; +import { LayoutEditor } from "./client"; + +export default async function TierlistLayoutPage() { + if (!(await adminCheck())) redirect("/login"); + + const [tiers, columns] = await Promise.all([ + db + .select() + .from(tierlistTiers) + .orderBy(tierlistTiers.order, tierlistTiers.id), + db + .select() + .from(tierlistColumns) + .orderBy(tierlistColumns.order, tierlistColumns.id), + ]); + + return ; +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/tl/ver/page.tsx b/app/(ui)/admin/tl/ver/page.tsx deleted file mode 100755 index db21788..0000000 --- a/app/(ui)/admin/tl/ver/page.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { desc } from "drizzle-orm"; -import { ExternalLink, PencilLine, PlusIcon } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; -import { SimpleTooltip } from "@/components/tooltip"; -import { Button } from "@/components/ui/button"; -import { Kbd } from "@/components/ui/kbd"; -import { db } from "@/lib/db"; -import { tierlistTypes, tierlistVersions } from "@/lib/db/schema"; -import { cn } from "@/lib/utils"; - -export default async function TierlistManagerPage() { - const types = await db.select().from(tierlistTypes).orderBy(tierlistTypes.id); - const versionsList = await db - .select() - .from(tierlistVersions) - .orderBy(desc(tierlistVersions.order)); - const vers = types.map((t) => ({ - ...t, - versions: versionsList.filter((v) => v.type === t.id), - })); - - return ( -
- - {vers.map((t) => ( -
-
-
- {t.name} - - {t.id} - {t.mode} - -
-
- - - - - - -
-
-
- {t.versions.map((e) => ( -
- {e.image ? ( - <> - {e.name} -
- {e.name} {e.id} -
- - ) : ( - e.name - )} -
- - - - - - -
-
- ))} -
-
- ))} -
- ); -} diff --git a/app/(ui)/artifact/admin/client.tsx b/app/(ui)/artifact/admin/client.tsx index 3a345ef..ccbb919 100755 --- a/app/(ui)/artifact/admin/client.tsx +++ b/app/(ui)/artifact/admin/client.tsx @@ -126,8 +126,8 @@ export function SidebarLink({ { - setChecked((x) => !x); + onCheckedChange={async (checked) => { + setChecked(checked === true); await toggleCheck(submission.id); stop(); }} diff --git a/app/(ui)/artifact/form.tsx b/app/(ui)/artifact/form.tsx index e99aae9..94da125 100755 --- a/app/(ui)/artifact/form.tsx +++ b/app/(ui)/artifact/form.tsx @@ -2,10 +2,12 @@ import { useRouter } from "next/navigation"; import posthog from "posthog-js"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { checkEnkaStatus, submitArtifact } from "@/lib/api"; import { shared } from "@/lib/comms"; +import type { ActionSubmitContextData } from "@/components/action-submit-provider"; +import { ActionSubmitContext } from "@/components/action-submit-provider"; export function ArtifactFormWrapper({ edit, @@ -20,6 +22,12 @@ export function ArtifactFormWrapper({ const [, setWarningUid] = shared.state("warning.uid"); const [warningSrc, setWarningSrc] = shared.state("warning.src"); const [dataHold, setDataHold] = useState(); + const callback = useRef<() => void>(() => {}); + const context: ActionSubmitContextData = { + listenForComplete(complete) { + callback.current = complete; + }, + }; // Disable warning for 60 seconds shared.signal( @@ -34,7 +42,7 @@ export function ArtifactFormWrapper({ (data.get("character") ?? "").toString(), ); console.log(data.get("uid"), data.get("character"), check); - setWarningUid(data.get("character")?.toString()); + setWarningUid(data.get("uid")?.toString()); setWarningSrc("submit"); setDataHold(data); if (check) return setDialog(check); @@ -56,12 +64,16 @@ export function ArtifactFormWrapper({ .catch((e) => toast.error(`${e.message || e}`)); } return ( -
{ - e.preventDefault(); - submit(new FormData(e.target as HTMLFormElement)); - }} - /> + + { + e.preventDefault(); + submit(new FormData(e.target as HTMLFormElement)).finally(() => + setTimeout(callback.current, 1000), + ); + }} + /> + ); } diff --git a/app/(ui)/artifact/page.tsx b/app/(ui)/artifact/page.tsx index 0d58b87..d3870bd 100755 --- a/app/(ui)/artifact/page.tsx +++ b/app/(ui)/artifact/page.tsx @@ -40,6 +40,7 @@ import { import { ArtifactFormWrapper } from "./form"; import { LiveButton } from "./live"; import { RulesDialog } from "./rules"; +import { ActionButton } from "@/components/action-button"; export const metadata: Metadata = { title: "เสือกไอดีชาวบ้าน", @@ -152,12 +153,13 @@ export default async function ArtifactFormPage({
- - + +
@@ -221,57 +223,58 @@ export default async function ArtifactFormPage({ />
-
-
- -
- - - - - - - - - - - - อ่านกฏการลงคิว - - {editing ? ( - - ) : ( - - - - )} -
-
- - {count} / {config.limit < 0 ? "∞" : config.limit} คิว - - - - -
-
+ + + + + + + + + อ่านกฏการลงคิว + + {editing ? ( + + ) : ( + + + + )} +
+
+ + {count} / {config.limit < 0 ? "∞" : config.limit} คิว + + + = 0 && count >= config.limit)) && + !editing + } + > + {editing ? : } + + +
+ + {sid && !q && } diff --git a/app/(ui)/donate/admin/@modal/calendar/client.tsx b/app/(ui)/donate/admin/@modal/calendar/client.tsx new file mode 100644 index 0000000..00e6678 --- /dev/null +++ b/app/(ui)/donate/admin/@modal/calendar/client.tsx @@ -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 = {}; + 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 ( +
+
+
+ + + {monthLabel} + + +
+
+ รวม{" "} + + {total.toLocaleString()} + {" "} + บาท +
+
+ +
+ {DAYS.map((day) => ( +
+ {day} +
+ ))} + + {Array.from( + { length: firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1 }, + (_, index) => `pad-${index + 1}`, + ).map((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 ( + + + + + + + + {format( + new Date( + current.getFullYear(), + current.getMonth(), + day, + ), + "d MMM yyyy", + { locale: th }, + )} + + +
+ {dayRows.length === 0 && ( +

+ ไม่มีโดเนทในวันนี้ +

+ )} + {dayRows.map((row) => ( +
+
+ {row.method === "pp" ? ( + + + + ) : ( + + truemoney + + )} +
+
+
+
+ {row.name} +
+
+ {format(row.created, "HH:mm", { locale: th })} + {row.uid ? ` - UID ${row.uid}` : ""} +
+
+ + {row.amount.toLocaleString()} ฿ + +
+
+ ))} +
+ + + รวม {dayTotal.toLocaleString()} บาท + + +
+
+ ); + }, + )} +
+
+ ); +} diff --git a/app/(ui)/donate/admin/@modal/calendar/page.tsx b/app/(ui)/donate/admin/@modal/calendar/page.tsx new file mode 100644 index 0000000..a080e4f --- /dev/null +++ b/app/(ui)/donate/admin/@modal/calendar/page.tsx @@ -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`${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 ( + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/donate/admin/client.tsx b/app/(ui)/donate/admin/client.tsx index 3694d7d..f2fd458 100644 --- a/app/(ui)/donate/admin/client.tsx +++ b/app/(ui)/donate/admin/client.tsx @@ -11,12 +11,12 @@ import { 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 { type ComponentProps, useEffect, useRef, useState } from "react"; -import { useEllipsisVisible } from "react-hook-text-overflow"; import { DataTable } from "@/components/tantable"; import { SimpleTooltip } from "@/components/tooltip"; import { Button } from "@/components/ui/button"; @@ -32,6 +32,10 @@ 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"; +import { Checkbox } from "@/components/ui/checkbox"; +import { toggleCheck } from "@/lib/api"; // directly adapted for date instead of UUID function colorFor(date: Date) { @@ -43,7 +47,36 @@ function colorFor(date: Date) { return lePalette[seed % lePalette.length]; } -const columns: ColumnDef[] = [ +function ArtifactCheckbox({ + checked, + submissionId, +}: { + checked: boolean; + submissionId: string; +}) { + const [value, setValue] = useState(checked); + + useEffect(() => { + setValue(checked); + }, [checked]); + + return ( + { + setValue(checked === true); + await toggleCheck(submissionId); + }} + /> + ); +} + +const columns: ColumnDef< + typeof donations.$inferSelect & { + checked: boolean | null; + artifactSubmissionId: string | null; + } +>[] = [ { accessorKey: "created", header: "", @@ -60,6 +93,21 @@ const columns: ColumnDef[] = [ meta: { className: "w-1 p-0 relative" }, }, { accessorKey: "name", header: "ชื่อ", meta: { className: "w-50 truncate" } }, + { + accessorKey: "art_check", + header: "", + meta: { className: "w-6 truncate" }, + cell(row) { + const { artifactSubmissionId, checked } = row.row.original; + if (checked === null || !artifactSubmissionId) return; + return ( + + ); + }, + }, { accessorFn: (row) => `${row.amount}฿`, header: "จำนวน", @@ -68,11 +116,22 @@ const columns: ColumnDef[] = [ { accessorKey: "message", cell(props) { - const [overflow, ref] = useEllipsisVisible(); - return overflow ? ( - + const [open, setOpen] = useState(false); + return ( + o || setOpen(false)} + > -
+
{ + ev.preventDefault(); + setOpen(true); + }} + > {props.row.original.message}
@@ -84,10 +143,6 @@ const columns: ColumnDef[] = [
- ) : ( -
- {props.row.original.message} -
); }, header: "ข้อความ", @@ -135,7 +190,7 @@ const columns: ColumnDef[] = [ cell(row) { return ; }, - meta: { className: "w-27 p-0" }, + meta: { className: "w-36 p-0" }, }, ]; @@ -148,6 +203,15 @@ function ActionRow({ }) { return (
+ {row.method === "pp" ? ( + + ) : ( + truemoney + )} รวมวันนี้ {stats.today}฿
-
+ - รวมทั้งหมด + รวมเดือนนี้ {stats.total}฿ -
+
); } @@ -259,7 +326,10 @@ export function DonateAdminPage({ data, stats, }: { - data: (typeof donations.$inferSelect)[]; + data: (typeof donations.$inferSelect & { + checked: boolean | null; + artifactSubmissionId: string | null; + })[]; stats: { total: number; today: number; @@ -276,13 +346,13 @@ export function DonateAdminPage({
- โดเนททั้งหมด + โดเนทเดือนนี้ { + if (newDonates <= 0) return ((document.title = "โดเนททั้งหมด"), undefined); + var blink = true; + const interval = setInterval(() => { + blink = !blink; + document.title = blink ? "โดเนททั้งหมด" : `${newDonates} โดเนทใหม่`; + }, 1000); + return () => clearInterval(interval); + }, [newDonates]); + + useEffect(() => { + function listener() { + setNewDonates(0); + } + window.addEventListener("focus", listener); + return () => window.removeEventListener("focus", listener); + }, []); + useEffect(() => { if (!router) return; return sse.donate.subMany({ - ping: router.refresh.bind(router), + ping: () => (router.refresh(), setNewDonates((x) => x + 1)), update: router.refresh.bind(router), }).clean; }, [router]); diff --git a/app/(ui)/donate/admin/page.tsx b/app/(ui)/donate/admin/page.tsx index 1c86005..9288f2e 100644 --- a/app/(ui)/donate/admin/page.tsx +++ b/app/(ui)/donate/admin/page.tsx @@ -1,4 +1,6 @@ -import { desc, getTableColumns, sql, sum } from "drizzle-orm"; +import { endOfMonth, startOfMonth } from "date-fns"; +import { between, desc, getTableColumns, sql, sum } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { adminCheck } from "@/lib/auth"; @@ -10,28 +12,41 @@ export default async function () { if (!(await adminCheck())) redirect(`/login?next=${encodeURIComponent("/donate/admin")}`); + const now = new Date(); + const donation = alias(donations, "donation"); const [data, [stats]] = await Promise.all([ db .select({ - ...getTableColumns(donations), - image: sql`${donations.image} IS NOT NULL`, + ...getTableColumns(donation), + image: sql`${donation.image} IS NOT NULL`, + checked: sql` + (SELECT "artifact"."submissions"."checked" + FROM "artifact"."submissions" + WHERE "artifact"."submissions"."uid" = "donation"."uid") + `, + artifactSubmissionId: sql` + (SELECT "artifact"."submissions"."id" + FROM "artifact"."submissions" + WHERE "artifact"."submissions"."uid" = "donation"."uid") + `, }) - .from(donations) + .from(donation) .limit(100) - .orderBy(desc(donations.id)), + .orderBy(desc(donation.id)), db .select({ total: sum(donations.amount).mapWith(Number), today: sql` COALESCE( SUM(${donations.amount}) FILTER ( - WHERE ${donations.created} >= NOW() - INTERVAL '24 hours' + WHERE ${donations.created} >= date_trunc('day', NOW()) ), 0 ) `, }) .from(donations) + .where(between(donations.created, startOfMonth(now), endOfMonth(now))) .limit(1), ]); return ; diff --git a/app/(ui)/donate/api.ts b/app/(ui)/donate/api.ts new file mode 100644 index 0000000..7544645 --- /dev/null +++ b/app/(ui)/donate/api.ts @@ -0,0 +1,18 @@ +"use server"; + +import { generatePromptPayPayload } from "@/lib/promptpay"; +import { env } from "bun"; + +const { PROMPTPAY_IDENTIFIER, PROMPTPAY_TYPE } = env; +if (!PROMPTPAY_IDENTIFIER || !PROMPTPAY_TYPE) + console.error("Warning: Missing PromptPay configuration!"); + +export async function generateQrcode(amount: number | string) { + return generatePromptPayPayload({ + identifier: { + type: PROMPTPAY_TYPE! as "mobile" | "nationalId" | "ewallet", + value: PROMPTPAY_IDENTIFIER!, + }, + amount, + }); +} diff --git a/app/(ui)/donate/page.tsx b/app/(ui)/donate/page.tsx index 4ea8606..4f65805 100644 --- a/app/(ui)/donate/page.tsx +++ b/app/(ui)/donate/page.tsx @@ -1,9 +1,8 @@ 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"; -import PromptpayImage from "#/assets/promptpay.jpg"; import TruemoneyIcon from "#/assets/tmn.webp"; import DonateLogo from "#/logos/donate.webp"; import Cropper from "@/components/cropper"; @@ -37,6 +36,7 @@ import { } from "../rubgram/admin/@modal/manual/client"; import { DownloadButton } from "../rubgram/client"; import Link from "next/link"; +import { DynamicPPQR } from "./ppqr"; const { TMN_DEST_PHONE_NUM, SASTIFY_API_PRIVKEY } = process.env as Record< string, @@ -117,6 +117,14 @@ export default async function () { const ph = getPostHogClient(); const distinctId = crypto.randomUUID(); + if ($.message && $.artifact === "false") { + // @ts-expect-error + $.artifact = "true"; + const match = $.message.match(/\b(?:[0-35-9]|18)\d{8}\b/); + // @ts-expect-error + if (match) $.uid = match[0]; + } + return await db.transaction(async (tx): Promise => { if ($.type === "pp") { const arrayBuffer = await $.slip.arrayBuffer(); @@ -205,6 +213,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, @@ -319,6 +328,15 @@ export default async function () { label="วิธีการโอนเงิน" name="type" tabs={[ + { + label: ( + + + PromptPay + + ), + value: "pp", + }, { label: ( @@ -332,46 +350,49 @@ export default async function () { ), value: "tmn", }, - { - label: ( - - - PromptPay - - ), - value: "pp", - }, ]} > - - - + {artifactConfig.donateTruemoney ? ( + + + + ) : ( +
+ {" "} + ขณะนี้การโอนเงินด้วยทรูมันนี่ใช้ไม่ได้ชั่วคราว +
+ )}
-
- Promptpay QR Code -
- บัญชีรับโดเนท - - ผู้รับ: นาย พัชรพล พลพันธุ์ - - - บัญชี: xxx-x-x8666-x - - - เลขที่อ้างอิง: 004999056945438 - - + {artifactConfig.donatePromptpay ? ( + <> +
+ +
+ บัญชีรับโดเนท + + ผู้รับ: นาย พัชรพล พลพันธุ์ + + + บัญชี: xxx-x-x8666-x + + + เลขที่อ้างอิง: 004999056945438 + + +
+
+ + + + + ) : ( +
+ {" "} + ขณะนี้การโอนเงินด้วยพร้อมเพย์ใช้ไม่ได้ชั่วคราว
-
- - - + )}
diff --git a/app/(ui)/donate/ppqr.tsx b/app/(ui)/donate/ppqr.tsx new file mode 100644 index 0000000..0f0f844 --- /dev/null +++ b/app/(ui)/donate/ppqr.tsx @@ -0,0 +1,67 @@ +"use client"; +import PromptpayImage from "#/assets/promptpay.jpg"; +import { useFormContext } from "@/components/form"; +import Image from "next/image"; +import { useEffect, useState } from "react"; +import QRCode from "react-qr-code"; +import { generateQrcode } from "./api"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export function DynamicPPQR() { + const form = useFormContext(); + const [current, setCurrent] = useState(form.values.amount as string); + const [qrcode, setQrcode] = useState(""); + const [changed, setChanged] = useState(false); + + useEffect(() => { + const amount = form.values.amount as string; + if (!amount || Number.isNaN(Number(amount))) { + setCurrent(""); + setQrcode(""); + setChanged(false); + return; + } + let timeout: any; + generateQrcode(amount).then( + (v) => ( + setQrcode(v), + setCurrent(amount), + setChanged(true), + (timeout = setTimeout(() => setChanged(false), 2000)) + ), + ); + return () => clearTimeout(timeout); + }, [form.values.amount]); + return ( + + + {form.values.amount ? ( + current === form.values.amount ? ( +
+ +
+ ) : ( +
+ Promptpay QR Code +
+ ) + ) : ( + Promptpay QR Code + )} +
+ จำนวน {current} บาท +
+ ); +} diff --git a/app/(ui)/donate/slip/[id]/client.tsx b/app/(ui)/donate/slip/[id]/client.tsx new file mode 100644 index 0000000..361255a --- /dev/null +++ b/app/(ui)/donate/slip/[id]/client.tsx @@ -0,0 +1,75 @@ +// oxlint-disable next/no-img-element +"use client"; + +import { Upload } from "lucide-react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { CircularProgress } from "@/components/customized/progress/circular-progress"; + +export function PageFileInput({ + onValueChange, +}: { + onValueChange?: (value: File) => void; +}) { + const fileInput = useRef(null); + const submitButton = useRef(null); + const [value, setValue] = useState(); + const [progress, setProgress] = useState(0); + const updateFormValue = useEffectEvent((file: File) => { + onValueChange?.(file); + }); + useEffect(() => { + if (!value) return; + updateFormValue(value); + setTimeout(() => submitButton.current?.click(), 100); + setProgress(0); + const start = Date.now(); + + const interval = setInterval(() => { + const t = Math.min((Date.now() - start) / 10000, 1); + const eased = 1 - Math.pow(1 - t, 2); + + setProgress(eased * 100); + + if (t >= 1) clearInterval(interval); + }, 16); + return () => clearInterval(interval); + }, [value]); + return ( +
fileInput.current?.click()} + > +
+ {value ? ( + <> + `${progress}%`} + showLabel + size={120} + value={Math.round(progress)} + /> +
กำลังอัพโหลด กรุณาอย่าปิดหน้านี้
+ + ) : ( + <> +
+ +
+
แตะเพื่อเลือกไฟล์สลิป
+ + )} +
+ setValue(fileInput.current?.files?.[0])} + /> +
+ ); +} diff --git a/app/(ui)/donate/slip/[id]/page.tsx b/app/(ui)/donate/slip/[id]/page.tsx new file mode 100644 index 0000000..b5d6b76 --- /dev/null +++ b/app/(ui)/donate/slip/[id]/page.tsx @@ -0,0 +1,56 @@ +import { db } from "@/lib/db"; +import type { TUploadKey } from "@/lib/db/schema"; +import { slipSync } from "@/lib/db/schema"; +import { sse } from "@/lib/db/sse-endpoints"; +import { eq } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import z from "zod"; +import { PageFileInput } from "./client"; +import { FormInput, FormProvider } from "@/components/form"; +import { formParse } from "@/components/form-submit"; + +const Schema = z.object({ + slip: z.file(), +}); + +export default async function DonateMobileSlipUploadPage({ + params, +}: PageProps<"/donate/slip/[id]">) { + const { id } = (await params) as { id: TUploadKey }; + const [{ trackingKey } = {}] = await db + .select({ + trackingKey: slipSync.trackingKey, + }) + .from(slipSync) + .where(eq(slipSync.uploadKey, id)); + if (!trackingKey) redirect("/donate"); + sse.slip_sync.pub("connected", trackingKey); + async function submit(data: FormData) { + "use server"; + + const { $, error } = formParse(Schema, data); + if (error) return { error }; + const { slip } = $; + + await db + .update(slipSync) + .set({ + name: slip.name, + type: slip.type, + size: `${slip.size}`, + data: Buffer.from(await slip.arrayBuffer()), + }) + .where(eq(slipSync.uploadKey, id)); + + sse.slip_sync.pub("complete", trackingKey!); + redirect("/donate/slip/complete"); + } + + return ( + + + + + + ); +} diff --git a/app/(ui)/donate/slip/complete/page.tsx b/app/(ui)/donate/slip/complete/page.tsx new file mode 100644 index 0000000..b043ae4 --- /dev/null +++ b/app/(ui)/donate/slip/complete/page.tsx @@ -0,0 +1,14 @@ +import { Check } from "lucide-react"; + +export default function PageFileInput() { + return ( +
+
+
+ +
+
อัพโหลดเสร็จเรียบร้อย ปิดหน้านี้ได้
+
+
+ ); +} diff --git a/app/(ui)/guide/page.tsx b/app/(ui)/guide/_page.tsx similarity index 100% rename from app/(ui)/guide/page.tsx rename to app/(ui)/guide/_page.tsx diff --git a/app/(ui)/guide/route.ts b/app/(ui)/guide/route.ts new file mode 100644 index 0000000..675d7a6 --- /dev/null +++ b/app/(ui)/guide/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.redirect("https://guide.sudloh.com/"); +} diff --git a/app/(ui)/rubgram/admin/@modal/manual/api.ts b/app/(ui)/rubgram/admin/@modal/manual/api.ts new file mode 100644 index 0000000..6cf492c --- /dev/null +++ b/app/(ui)/rubgram/admin/@modal/manual/api.ts @@ -0,0 +1,31 @@ +"use server"; + +import { db } from "@/lib/db"; +import type { TAccessKey } from "@/lib/db/schema"; +import { slipSync } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; + +export async function startMobileUpload() { + return await db + .insert(slipSync) + .values({}) + .returning() + .then((r) => r.pop()!); +} + +export async function retrieveMobileUpload(accessKey: TAccessKey) { + const object = await db + .delete(slipSync) + .where(eq(slipSync.accessKey, accessKey)) + .returning() + .then((r) => r.pop()!); + const fd = new FormData(); + fd.set( + "file", + new File([Buffer.from(object.data!)], object.name!, { + type: object.type!, + }), + ); + fd.set("name", object.name!); + return fd; +} diff --git a/app/(ui)/rubgram/admin/@modal/manual/client.tsx b/app/(ui)/rubgram/admin/@modal/manual/client.tsx index c6754da..224f011 100644 --- a/app/(ui)/rubgram/admin/@modal/manual/client.tsx +++ b/app/(ui)/rubgram/admin/@modal/manual/client.tsx @@ -1,7 +1,7 @@ /** biome-ignore-all lint/a11y/noStaticElementInteractions: yes maam */ "use client"; -import { FileSearch2, RefreshCw, X } from "lucide-react"; +import { FileSearch2, RefreshCw, TabletSmartphone, X } from "lucide-react"; import { type ComponentProps, useEffect, useRef, useState } from "react"; import { b2sClient } from "@/app/(ui)/admin/cdn/table"; import { VirtualizedComboBox } from "@/components/combobox"; @@ -26,6 +26,12 @@ import { } from "@/components/ui/multi-select"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; +import { ActionButton } from "@/components/action-button"; +import { retrieveMobileUpload, startMobileUpload } from "./api"; +import { sse } from "@/lib/db/sse-endpoints"; +import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog"; +import QRCode from "react-qr-code"; +import { Status, StatusIndicator } from "@/components/ui/status"; export function CurrencyInput(props: ComponentProps) { return ( @@ -98,6 +104,11 @@ export function SlipUpload({ }) { const ref = useRef(null); const [value, setValue] = useState(externalValue); + const [muUploadKey, setMuUploadKey] = useState(); + const [muState, setMuState] = useState<"waiting" | "connected" | "completed">( + "waiting", + ); + const [muReject, setMuReject] = useState<(reason?: any) => void>(() => {}); const isSelected = !!value?.name; useEffect(() => { @@ -114,8 +125,68 @@ export function SlipUpload({ function choose() { ref.current?.click(); } + + async function mobileUpload() { + setMuState("waiting"); + + // 1. create sync entry + const { trackingKey, accessKey, uploadKey } = await startMobileUpload(); + + // 2. watch for slip_sync.complete for that tracking ID + let completedResolve: () => void; + const completedPromise = new Promise( + (r, j) => ((completedResolve = r), setMuReject(() => j)), + ); + const ev = sse.slip_sync.subMany({ + complete(tid) { + if (trackingKey !== tid) return; + setMuState("completed"); + completedResolve(); + }, + connected(tid) { + console.log(tid, trackingKey); + if (trackingKey !== tid) return; + setMuState("connected"); + }, + }); + + // 3. show qr/share dialog + setMuUploadKey(uploadKey); + // window.open(`http://localhost:3000/donate/slip/${uploadKey}`); + + try { + // 4. user open link on mobile + // 5. user upload image + // 6. image updates the sync entry and trigger slip_sync.complete + await completedPromise; + + // 7. pc client fetch image and delete the sync entry + const fd = await retrieveMobileUpload(accessKey); + const f = fd.get("file") as File; + const file = new File([f], fd.get("name") as string, { + type: f.type, + lastModified: f.lastModified, + }); + + // 8. pc client stop watch + ev.clean(); + + // 9. close dialog and set slip input? + setMuUploadKey(undefined); + console.log(file); + setValue(file); + onValueChange?.(file); + } catch (e) { + console.error(e); + setMuUploadKey(undefined); + ev.clean(); + + throw e; + } + } + return ( - <> +
+
+ + + +
- + state || muReject?.("user abort")} + > + + + อัพโหลดสลิปจากมือถือ + +
+
+ +
+ + สแกนด้วยโทรศัพท์เพื่อเลือกสลิปจาก Gallery และอัพโหลดโดยตรงมาที่นี่ + + + {muState === "waiting" ? ( + <> + + + + กำลังรอการเชื่อมต่อ + + ) : muState === "connected" ? ( + <> + + + + เชื่อมต่อแล้ว กำลังรออัพโหลดรูป + + ) : ( + <> + + + + กำลังดาวน์โหลดเข้าเครื่องนี + + )} + +
+
+
+
); } @@ -209,6 +337,7 @@ export function UserSelect({ variant="outline" onClick={() => setBump(Date.now())} disabled={loading} + type="button" > {loading ? : } diff --git a/app/(ui)/rubgram/api.ts b/app/(ui)/rubgram/api.ts index daab667..998eb02 100755 --- a/app/(ui)/rubgram/api.ts +++ b/app/(ui)/rubgram/api.ts @@ -369,7 +369,7 @@ export async function getDiscordSession() { export async function loginDiscord() { redirect( - `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(new URL("/rubgram/callback", BASE_URL).href)}&scope=identify+guilds.join+guilds`, + `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(`${BASE_URL}/rubgram/callback`)}&scope=identify+guilds.join+guilds`, ); } diff --git a/app/(ui)/rubgram/page.tsx b/app/(ui)/rubgram/page.tsx index f7e8d16..ccfc37c 100755 --- a/app/(ui)/rubgram/page.tsx +++ b/app/(ui)/rubgram/page.tsx @@ -299,13 +299,11 @@ export default async function EndgamePage({ {q && ( - - - - - + + + แน่ใจหรอ diff --git a/app/(ui)/tl/[type]/[ver]/admin/page.tsx b/app/(ui)/tl/[type]/[ver]/admin/page.tsx index bdb7267..8d35aaa 100755 --- a/app/(ui)/tl/[type]/[ver]/admin/page.tsx +++ b/app/(ui)/tl/[type]/[ver]/admin/page.tsx @@ -1,17 +1,7 @@ -import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { notFound, redirect } from "next/navigation"; import { ViewTransition } from "react"; import { adminCheck } from "@/lib/auth"; -import { db } from "@/lib/db"; -import { - characters, - tierlistBadges, - tierlistColumns, - tierlistTiers, - tierlistTypes, - tierlistVersions, - versions, -} from "@/lib/db/schema"; +import { getTierlistConfig } from "@/lib/tierlist"; import { TierList } from "../tierlist"; export default async function TierlistPage({ @@ -21,64 +11,7 @@ export default async function TierlistPage({ }) { if (!(await adminCheck())) redirect("/login"); const { type, ver } = await params; - const config = await db.transaction(async (tx) => { - // resolve version info - const [version] = await tx - .select() - .from(tierlistVersions) - .where( - and(eq(tierlistVersions.type, type), eq(tierlistVersions.id, ver)), - ); - if (!version) return; - const [typeInfo] = await tx - .select() - .from(tierlistTypes) - .where(eq(tierlistTypes.id, type)); - - // resolve `from` fields - const vers = await tx - .select({ id: versions.id, from: versions.from }) - .from(versions); - const ids: string[] = []; - let cur: string | null = version.from; - while (cur) { - ids.push(cur); - const r = vers.find((v) => v.id === cur); - cur = r?.from ?? null; - } - - const chars = - ids.length > 0 - ? await tx - .select() - .from(characters) - .where(inArray(characters.version, ids)) - .orderBy(characters.order) - : ([] as (typeof characters.$inferSelect)[]); - - // fetch rows, columns, badges data... - const tiers = await tx - .select() - .from(tierlistTiers) - .orderBy(tierlistTiers.order); - const columns = await tx - .select() - .from(tierlistColumns) - .orderBy(tierlistColumns.order); - const badgesList = await tx - .select() - .from(tierlistBadges) - .orderBy(tierlistBadges.order) - .where( - or(isNull(tierlistBadges.type), eq(tierlistBadges.type, version.type)), - ); - const badges = badgesList.map((b) => ({ - ...b, - tier: tiers.filter((t) => t.badges?.includes(b.id)).map((t) => t.id), - })); - - return { type: typeInfo, version, tiers, columns, badges, chars }; - }); + const config = await getTierlistConfig(type, ver); if (!config) // console.log(config); notFound(); diff --git a/app/(ui)/tl/[type]/[ver]/cell.tsx b/app/(ui)/tl/[type]/[ver]/cell.tsx index 331103c..ab92f81 100755 --- a/app/(ui)/tl/[type]/[ver]/cell.tsx +++ b/app/(ui)/tl/[type]/[ver]/cell.tsx @@ -7,7 +7,6 @@ import { SortableContext, useSortable, } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; import { type ReactNode, useContext } from "react"; import type { tierlistColumns, tierlistTiers } from "@/lib/db/schema"; import { TierListContext } from "./context"; @@ -69,7 +68,9 @@ export function SortableDraggable({ } = useSortable({ id, disabled: !editable }); const style = { - transform: CSS.Transform.toString(transform), + transform: transform + ? `translate3d(${transform.x}px, ${transform.y}px, 0) scaleX(${transform.scaleX}) scaleY(${transform.scaleY})` + : undefined, transition, opacity: isDragging ? 0.5 : 1, }; diff --git a/app/(ui)/tl/[type]/[ver]/page.tsx b/app/(ui)/tl/[type]/[ver]/page.tsx index e8a8ac0..619b9a4 100755 --- a/app/(ui)/tl/[type]/[ver]/page.tsx +++ b/app/(ui)/tl/[type]/[ver]/page.tsx @@ -1,16 +1,9 @@ -import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { db } from "@/lib/db"; -import { - characters, - tierlistBadges, - tierlistColumns, - tierlistTiers, - tierlistTypes, - tierlistVersions, - versions, -} from "@/lib/db/schema"; +import { tierlistTypes, tierlistVersions } from "@/lib/db/schema"; +import { getTierlistConfig } from "@/lib/tierlist"; import { TierList } from "./tierlist"; export async function generateMetadata({ @@ -48,64 +41,7 @@ export default async function TierlistPage({ params: Promise<{ type: string; ver: string }>; }) { const { type, ver } = await params; - const config = await db.transaction(async (tx) => { - // resolve version info - const [version] = await tx - .select() - .from(tierlistVersions) - .where( - and(eq(tierlistVersions.type, type), eq(tierlistVersions.id, ver)), - ); - if (!version) return; - const [typeInfo] = await tx - .select() - .from(tierlistTypes) - .where(eq(tierlistTypes.id, type)); - - // resolve `from` fields - const vers = await tx - .select({ id: versions.id, from: versions.from }) - .from(versions); - const ids: string[] = []; - let cur: string | null = version.from; - while (cur) { - ids.push(cur); - const r = vers.find((v) => v.id === cur); - cur = r?.from ?? null; - } - - const chars = - ids.length > 0 - ? await tx - .select() - .from(characters) - .where(inArray(characters.version, ids)) - .orderBy(characters.order) - : ([] as (typeof characters.$inferSelect)[]); - - // fetch rows, columns, badges data... - const [tiers, columns, badgesList] = await Promise.all([ - tx.select().from(tierlistTiers).orderBy(tierlistTiers.order), - tx.select().from(tierlistColumns).orderBy(tierlistColumns.order), - tx - .select() - .from(tierlistBadges) - .orderBy(tierlistBadges.order) - .where( - or( - isNull(tierlistBadges.type), - eq(tierlistBadges.type, version.type), - ), - ), - ]); - const badges = badgesList.map((b) => ({ - ...b, - tier: tiers.filter((t) => t.badges?.includes(b.id)).map((t) => t.id), - })); - - return { type: typeInfo, version, tiers, columns, badges, chars }; - }); - // console.log(config); + const config = await getTierlistConfig(type, ver); if (!config) notFound(); return ; // return
{JSON.stringify(config, null, 2)}
; diff --git a/app/api/amber/sync/route.ts b/app/api/amber/sync/route.ts index 7dc7dc9..100daf1 100644 --- a/app/api/amber/sync/route.ts +++ b/app/api/amber/sync/route.ts @@ -1,15 +1,13 @@ -import { exec } from "node:child_process"; -import { env } from "node:process"; -import { promisify } from "node:util"; import { actionLog } from "@/lib/api"; import { adminCheck } from "@/lib/auth"; +import { syncAmber } from "@/util/sync"; export async function GET() { if (!(await adminCheck())) return new Response("Unauthorized", { status: 401 }); - const res = await promisify(exec)("bun util/sync 2>&1", { - env: { ...env, NO_AUTH_CHECK: "1" }, - }); - await actionLog("Triggered an Amber sync from API", { result: res.stdout }); - return new Response(res.stdout); + await syncAmber(); + await actionLog("Triggered an Amber sync from API"); + return new Response( + "OK(log is wip, check `kubectl logs -fn buzz deployments/app`)", + ); } diff --git a/app/api/card/[sub]/route.ts b/app/api/card/[sub]/route.ts index a1f0eb2..d169710 100755 --- a/app/api/card/[sub]/route.ts +++ b/app/api/card/[sub]/route.ts @@ -13,10 +13,15 @@ export async function GET( db.select().from(cards).where(eq(cards.submission, subId)), ]); if (image?.image) return new Response(new Uint8Array(image.image)); + if (!sub) return new Response(`Unknown submission: ${sub}`, { status: 404 }); + if (!sub.char) + return new Response(`This submission isn't character-specific.`, { + status: 422, + }); const [char] = await db .select() .from(characters) - .where(eq(characters.name, sub.char ?? "THISISNULLANDNOTSUPPOSEDTOMATCH")); + .where(eq(characters.name, sub.char)); if (!char) return new Response(`Unknown character: ${sub.char}`, { status: 500 }); const card = await fetch( diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..17a55ce --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,43 @@ +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}`, + ); +} diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts new file mode 100644 index 0000000..53a6264 --- /dev/null +++ b/app/api/debug/route.ts @@ -0,0 +1,126 @@ +import { youtubeCache } from "@/lib/adaptive-cache"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { redis } from "@/lib/db/redis"; +import { env } from "bun"; +import { sql } from "drizzle-orm"; +import type { NextRequest } from "next/server"; + +async function probe(name: string, fn: () => Promise) { + const started = performance.now(); + + try { + const result = await fn(); + + return { + name, + ok: true, + latencyMs: performance.now() - started, + result, + }; + } catch (error) { + return { + name, + ok: false, + latencyMs: performance.now() - started, + error: + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause, + } + : error, + }; + } +} + +export async function GET(request: NextRequest) { + try { + if (!(await adminCheck())) { + return new Response("Unauthorized", { status: 401 }); + } + } catch { + // idiomatic fallback + // even though this is extremely risky, we need it + console.error("/api/debug AUTH CHECK ERROR"); + // use REDIS_URL which only I can memorize as the password, + // or IF THAT fails too, use a static string, which shouldn't ever happen + if ( + request.nextUrl.searchParams.get("pass") !== + (env.REDIS_URL || "absolute_solver") + ) + return new Response("Unauthorized (Fallback)", { status: 401 }); + } + + const probes = await Promise.all([ + probe("redis", async () => { + const started = performance.now(); + const result = await withTimeout(redis!.ping(), 5000); + + return { + result, + latencyMs: performance.now() - started, + }; + }), + + probe("postgres", async () => { + const started = performance.now(); + const result = await withTimeout(db.execute(sql`SELECT 1`), 5000); + + return { + result, + latencyMs: performance.now() - started, + }; + }), + ]); + + return Response.json({ + timestamp: new Date().toISOString(), + + runtime: safe({ + bun: Bun.version, + node: process.version, + platform: process.platform, + arch: process.arch, + pid: process.pid, + uptime: process.uptime(), + cwd: process.cwd(), + }), + + memory: process.memoryUsage(), + + eventLoop: { + now: performance.now(), + }, + + request: { + url: request.url, + headers: safe(Object.fromEntries(request.headers)), + }, + + ytCache: await probe("youtubeCache", async () => youtubeCache.debugStats()), + + env, + + probes, + }); +} + +async function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms), + ), + ]); +} + +function safe(value: unknown) { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return String(value); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 8c691c3..2983aaa 100755 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,7 +1,11 @@ +import { GoogleGenAI } from "@google/genai"; +import { env } from "bun"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { redis as redisShared } from "@/lib/db/redis"; +const { DISCORD_BOT_TOKEN, GEMINI_TTS_API_KEY } = env as Record; + const redis = redisShared!; export const revalidate = 60; @@ -20,16 +24,24 @@ export async function GET() { red ? (await redis.exists(`health:${k}`)) ? (await redis.get(`health:${k}`)) === "ok" - : await v().then(async (v) => { - if (v) await redis.setex(`health:${k}`, 900, "ok"); - return v; - }) - : await v(), + : await v() + .then(async (r) => { + const v = r instanceof Response ? r.ok : !!r; + if (v) await redis.setex(`health:${k}`, 900, "ok"); + return v; + }) + .catch(() => false) + : await v() + .then((r) => (r instanceof Response ? r.ok : !!r)) + .catch(() => false), ] as const, ), ), ); - return Response.json({ red, ...res }); + return Response.json( + { red, ...res }, + { status: red ? (res.database ? 200 : 201) : 503 }, + ); } const checks = { @@ -40,19 +52,52 @@ const checks = { .catch(() => false); }, async enka() { - return await fetch("https://enka.network/api/uid/888888888/?info", { + return fetch("https://enka.network/api/uid/888888888/?info", { headers: { "User-Agent": "Buzz, https://buzz.sudloh.com/api/health" }, signal: AbortSignal.timeout(5000), - }) - .then((r) => r.ok) - .catch(() => false); + }); }, async amber() { - return await fetch("https://gi.yatta.moe/api/v2/en/avatar", { + return fetch("https://gi.yatta.moe/api/v2/en/avatar", { headers: { "User-Agent": "Buzz, https://buzz.sudloh.com/api/health" }, signal: AbortSignal.timeout(5000), - }) - .then((r) => r.ok) + }); + }, + async yt() { + return fetch("https://buzz.sudloh.com/api/live?last=true", { + signal: AbortSignal.timeout(5000), + }); + }, + async card() { + return fetch("https://api.astrxl.dev/v1/card/genshin/stats", { + signal: AbortSignal.timeout(5000), + }); + }, + async slip() { + return fetch("https://api.slipok.com/", { + signal: AbortSignal.timeout(5000), + }); + }, + async tts() { + const client = new GoogleGenAI({ + apiKey: GEMINI_TTS_API_KEY.split(",").pop(), + }); + return client.models + .list() + .then(() => true) .catch(() => false); }, -} as const; + async tmn() { + return fetch("https://api.sastify.xyz/health", { + signal: AbortSignal.timeout(5000), + }); + }, + async discord() { + return fetch("https://discord.com/api/v10/users/@me", { + signal: AbortSignal.timeout(5000), + headers: { + Authorization: `Bot ${DISCORD_BOT_TOKEN}`, + }, + }); + }, +} satisfies Record Promise>; diff --git a/app/api/live/route.ts b/app/api/live/route.ts index 8d396cc..180e2ef 100755 --- a/app/api/live/route.ts +++ b/app/api/live/route.ts @@ -1,7 +1,7 @@ +import type { NextRequest } from "next/server"; 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 = | { @@ -54,27 +54,34 @@ type APISearchResponse = { items: APISearchResource[]; }; -export async function GET() { +var lastResponse: Response; + +export async function GET(req: NextRequest) { + if (req.nextUrl.searchParams.has("last") && lastResponse) + return lastResponse.clone(); + const channelId = process.env.YOUTUBE_CHANNEL_ID; const apiKey = process.env.YOUTUBE_API_KEY; if (!apiKey || !channelId) return NextResponse.json("none"); - const response = await fetch( + const response = await youtubeCache.fetch( `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`, { - next: { - revalidate: 900, // refresh every 15 minutes - }, - cache: "force-cache", + cache: "no-store", }, ); - if (!response.ok) - throw new Error(`YouTube API error: ${response.statusText}`); + if (!response.ok) { + console.error(`YouTube API error: ${response.statusText}`); + return NextResponse.json("none"); + } const data: APISearchResponse = await response.json(); - if (data.items.length <= 0) return NextResponse.json("none"); + + if (data.items.length === 0) + return NextResponse.json("none"); + const live = data.items[0]; const res: YoutubeLiveInfo = { @@ -85,5 +92,10 @@ export async function GET() { }; sse.active.pub("live", res); - return NextResponse.json(res); + + return (lastResponse = NextResponse.json(res)); } + +export const POST = GET; + +export const revalidate = 60; diff --git a/app/api/tts/route.ts b/app/api/tts/route.ts index 6f99f70..45f77b6 100644 --- a/app/api/tts/route.ts +++ b/app/api/tts/route.ts @@ -12,7 +12,7 @@ const { DONATE_WIDGET_KEY, GEMINI_TTS_API_KEY } = process.env as Record< const MODELS = [ "gemini-2.5-flash-preview-tts", - "gemini-3.1-flash-preview-tts", + // "gemini-3.1-flash-preview-tts", ] as const; const API_KEYS = GEMINI_TTS_API_KEY.split(",") .map((k) => k.trim()) diff --git a/app/client.tsx b/app/client.tsx index 1345ab9..a8e656d 100755 --- a/app/client.tsx +++ b/app/client.tsx @@ -42,36 +42,48 @@ 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()); + }, + announcement({ message, prefix, severity }) { + if (headless) return; + if (prefix && !window.location.pathname.startsWith(prefix)) return; + return toast[severity](message, { duration: Infinity }); + }, }, { endpoint: "/api/active", diff --git a/app/globals.css b/app/globals.css index 1777b7b..fcd2ac8 100755 --- a/app/globals.css +++ b/app/globals.css @@ -236,3 +236,12 @@ img { opacity: 1; } } + +[contenteditable="true"]:empty::before { + content: attr(data-placeholder); + color: #a0a0a0; + pointer-events: none; +} +[contenteditable="true"]:focus { + outline: none; +} diff --git a/app/widget/chat/page.tsx b/app/widget/chat/page.tsx new file mode 100644 index 0000000..7f14010 --- /dev/null +++ b/app/widget/chat/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Countdown } from "@/app/(ui)/rubgram/client"; +import type { YoutubeLiveInfo } from "@/app/api/live/route"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { throwNotOk } from "@/lib/utils"; +import { RefreshCw } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { MessageCircleOff } from "lucide-react"; + +export default function ChatWidget() { + const [cooldown, setCooldown] = useState(); + const [loading, setLoading] = useState(false); + const [notFound, setNotFound] = useState(false); + const router = useRouter(); + + const load = useCallback(async () => { + try { + setLoading(true); + // post prevents caching + const data: YoutubeLiveInfo = await fetch("/api/live", { method: "POST" }) + .then(throwNotOk) + .then((r) => r.json()); + if (data === "none") return setNotFound(true); + const vid = new URL(data.url).searchParams.get("v"); + router.push(`https://studio.youtube.com/live_chat?is_popout=1&v=${vid}`); + } catch (e) { + console.error(e); + setNotFound(true); + } finally { + setLoading(false); + } + }, [router]); + + useEffect(() => { + if (!notFound) load(); + }, [load, notFound]); + + return notFound ? ( +
+ + +
+ ) : ( +
+ +
+ ); +} diff --git a/backend/index.ts b/backend/index.ts index 2e35f2c..6e23a49 100755 --- a/backend/index.ts +++ b/backend/index.ts @@ -12,6 +12,7 @@ import { endgameSubmissions, endgameTypes, settings, + slipSync, submissions, tierlistColumns, tierlistTiers, @@ -60,6 +61,20 @@ function logger(group: string) { return t; } +async function checkMobileSyncExpiration() { + const { log, schedule } = logger("mobileSync"); + + const count = await db + .delete(slipSync) + .where(lt(slipSync.created, sql`NOW() - INTERVAL '1 hour'`)) + .returning({ id: slipSync.trackingKey }); + const removed = count.length; + + if (removed) log("Purged", removed, "sessions"); + + schedule([], checkMobileSyncExpiration); +} + async function checkRubgramExpiration() { const { log, schedule } = logger("rubgramExpiration"); const { removed } = await removeExpiredSubmissions(); @@ -272,7 +287,7 @@ async function cacheCards() { } else schedule(120, cacheCards); } -cron("0 0 */14 * *", async function syncAmber() { +cron("0 1 * * *", async function syncAmber() { const { log, error } = logger("syncAmber"); log("Syncing..."); const token = await issueInternalToken(); @@ -326,6 +341,7 @@ const redisSubscribers: Record< }; checkRubgramExpiration(); +checkMobileSyncExpiration(); seedDatabase(); cacheCards(); console.log("Tasks assigned"); diff --git a/bun.lock b/bun.lock index 216c1a0..d401cce 100644 --- a/bun.lock +++ b/bun.lock @@ -55,7 +55,7 @@ "drizzle-orm": "^0.44.7", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "lucide-react": "^0.562.0", + "lucide-react": "^1.27.0", "motion": "^12.40.0", "next": "16.2.6", "next-themes": "^0.4.6", @@ -68,6 +68,7 @@ "react-dom": "19.2.6", "react-hook-form": "^7.68.0", "react-hook-text-overflow": "^1.0.1", + "react-qr-code": "^2.2.0", "react-resizable-panels": "^3.0.6", "react-use": "^17.6.0", "recharts": "2.15.4", @@ -1056,7 +1057,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + "lucide-react": ["lucide-react@1.27.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1164,6 +1165,8 @@ "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], @@ -1182,6 +1185,8 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-qr-code": ["react-qr-code@2.2.0", "", { "dependencies": { "prop-types": "^15.8.1", "qrcode-generator": "^2.0.4" }, "peerDependencies": { "react": "*" } }, "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], diff --git a/components/action-button.tsx b/components/action-button.tsx index 036e796..d90377b 100644 --- a/components/action-button.tsx +++ b/components/action-button.tsx @@ -3,18 +3,24 @@ import type { MaybePromise } from "bun"; import { Check } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { use, useEffect, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { ActionSubmitContext } from "./action-submit-provider"; export function ActionButton({ action, children, onClick, + text, ...props -}: ComponentProps & { action: () => MaybePromise }) { +}: ComponentProps & { + action: (() => MaybePromise) | "provider"; + text?: string; +}) { const [state, setState] = useState<0 | 1 | 2>(0); + const ctx = use(ActionSubmitContext); useEffect(() => { if (state !== 2) return; @@ -26,10 +32,12 @@ export function ActionButton({ disabled={state > 0} {...props} onClick={async (ev) => { - onClick?.(ev); setState(1); + if (action === "provider") + await new Promise(ctx.listenForComplete); + onClick?.(ev); try { - await action(); + if (action !== "provider") await action(); setState(2); } catch (e) { toast.error("An error occured!"); @@ -38,7 +46,7 @@ export function ActionButton({ } }} > - {state === 0 ? children : state === 1 ? : } + {state === 0 ? children : state === 1 ? : } {text} ); } diff --git a/components/action-submit-provider.tsx b/components/action-submit-provider.tsx new file mode 100644 index 0000000..2d2e3a7 --- /dev/null +++ b/components/action-submit-provider.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { createContext } from "react"; + +export type ActionSubmitContextData = { + listenForComplete: (complete: () => void) => void; +}; + +export const ActionSubmitContext = createContext({ + listenForComplete: (r) => { + console.warn("submit context not caught"); + r(); + }, +}); diff --git a/components/customized/progress/circular-progress.tsx b/components/customized/progress/circular-progress.tsx new file mode 100644 index 0000000..ad1a65e --- /dev/null +++ b/components/customized/progress/circular-progress.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface CircularProgressProps { + value: number; + renderLabel?: (progress: number) => number | string; + size?: number; + strokeWidth?: number; + circleStrokeWidth?: number; + progressStrokeWidth?: number; + shape?: "square" | "round"; + className?: string; + progressClassName?: string; + labelClassName?: string; + showLabel?: boolean; +} + +export const CircularProgress = ({ + value, + renderLabel, + className, + progressClassName, + labelClassName, + showLabel, + shape = "round", + size = 100, + strokeWidth, + circleStrokeWidth = 10, + progressStrokeWidth = 10, +}: CircularProgressProps) => { + const radius = size / 2 - 10; + const circumference = Math.ceil(3.14 * radius * 2); + const percentage = Math.ceil(circumference * ((100 - value) / 100)); + + const viewBox = `-${size * 0.125} -${size * 0.125} ${size * 1.25} ${ + size * 1.25 + }`; + + return ( +
+ + {/* Base Circle */} + + + {/* Progress */} + + + {showLabel && ( +
+ {renderLabel ? renderLabel(value) : value} +
+ )} +
+ ); +}; diff --git a/components/fake-toast.tsx b/components/fake-toast.tsx new file mode 100644 index 0000000..652f218 --- /dev/null +++ b/components/fake-toast.tsx @@ -0,0 +1,57 @@ +import { Check, CircleAlert, CircleX, Info } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type ToastType = "success" | "info" | "warning" | "error"; + +const toastStyles: Record = { + success: "border-emerald-950 bg-emerald-950/70 text-emerald-300", + info: "border-blue-950 bg-blue-950/70 text-blue-300", + warning: "border-yellow-950 bg-yellow-950/70 text-yellow-200", + error: "border-red-950 bg-red-950/70 text-red-200", +}; + +const toastIcons: Record = { + success: Check, + info: Info, + warning: CircleAlert, + error: CircleX, +}; + +export function FakeToast({ + type = "success", + children, + onIconClick, +}: { + type?: ToastType; + children: React.ReactNode; + onIconClick?: () => void; +}) { + const Icon = toastIcons[type]; + + return ( +
+ {onIconClick ? ( + + ) : ( +
+ ); +} diff --git a/components/form.tsx b/components/form.tsx index 64144c8..8ba71b9 100755 --- a/components/form.tsx +++ b/components/form.tsx @@ -234,6 +234,7 @@ export function FormProvider({ : typeof error === "string" ? error : "Something went wrong"; + if (message.includes("NEXT_REDIRECT")) return; toast.error(message); } finally { setLoading(false); diff --git a/components/ui/status.tsx b/components/ui/status.tsx new file mode 100644 index 0000000..71ab763 --- /dev/null +++ b/components/ui/status.tsx @@ -0,0 +1,81 @@ +"use client" + +import type { ComponentProps, HTMLAttributes } from "react" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +export type StatusProps = ComponentProps & { + status: "online" | "offline" | "maintenance" | "degraded" +} + +export const Status = ({ className, status, ...props }: StatusProps) => ( + +) + +export type StatusIndicatorProps = HTMLAttributes + +export const StatusIndicator = ({ className, ...props }: StatusIndicatorProps) => ( + + + + +) + +export type StatusLabelProps = HTMLAttributes + +export const StatusLabel = ({ className, children, ...props }: StatusLabelProps) => ( + + {children ?? ( + <> + Online + Offline + Maintenance + Degraded + + )} + +) + +// Demo +export function Demo() { + return ( +
+ + + + + + + + + + + + + + + + +
+ ) +} diff --git a/k8s/README.md b/k8s/README.md index 685c5de..8895361 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -69,7 +69,7 @@ ### Resource Limits -- **app**: 2 replicas, limits 1 CPU / 1 Gi memory, requests 500m / 512 Mi. Liveness probe on `/api/health` (60s interval). Readiness probe same. +- **app**: 2 replicas, limits 1 CPU / 1 Gi memory, requests 500m / 512 Mi. Liveness probe on `/api/health` (60s interval, 30s timeout). Readiness probe uses a 15s timeout. - **backend**: 1 replica, limits 1 CPU / 512 Mi memory, requests 250m / 256 Mi. No probes (no HTTP endpoint). - **db-migrate Job**: limits 1 CPU / 512 Mi, requests 250m / 256 Mi. diff --git a/k8s/deployment-app.yaml b/k8s/deployment-app.yaml index 7a0ecfb..9f07699 100644 --- a/k8s/deployment-app.yaml +++ b/k8s/deployment-app.yaml @@ -56,7 +56,7 @@ spec: port: 3000 initialDelaySeconds: 10 periodSeconds: 60 - timeoutSeconds: 5 + timeoutSeconds: 30 failureThreshold: 3 readinessProbe: httpGet: @@ -64,3 +64,4 @@ spec: port: 3000 initialDelaySeconds: 5 periodSeconds: 10 + timeoutSeconds: 15 diff --git a/lib/adaptive-cache.test.ts b/lib/adaptive-cache.test.ts new file mode 100644 index 0000000..4f7f322 --- /dev/null +++ b/lib/adaptive-cache.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, mock, test } from "bun:test"; +import { AdaptiveCache } from "./adaptive-cache"; + +class FakeRedis { + readonly strings = new Map(); + readonly sortedSets = new Map>(); + readonly commands: { command: string; args: string[] }[] = []; + + async get(key: string) { + const entry = this.strings.get(key); + if (!entry || entry.expires <= Date.now()) { + this.strings.delete(key); + return null; + } + return entry.value; + } + + async send(command: string, args: string[]): Promise { + this.commands.push({ command, args }); + + if (command === "DEL") return this.strings.delete(args[0]) ? 1 : 0; + if (command !== "EVAL") throw new Error(`Unsupported command: ${command}`); + + if (args[0].includes("return {")) { + const requests = this.sortedSet(args[2]); + const entries = this.sortedSet(args[3]); + const cutoff = Number(args[4]); + const now = Number(args[5]); + + for (const [member, score] of requests) + if (score < cutoff) requests.delete(member); + for (const [member, score] of entries) + if (score <= now) entries.delete(member); + + return [requests.size, entries.size]; + } + + const responseKey = args[2]; + const requests = this.sortedSet(args[3]); + const entries = this.sortedSet(args[4]); + const startedAt = Number(args[5]); + const requestMember = args[6]; + const cached = args[8]; + const remainingTtl = Number(args[9]); + const expiresAt = Number(args[10]); + const hash = args[11]; + + requests.set(requestMember, startedAt); + if (remainingTtl > 0) { + this.strings.set(responseKey, { + value: cached, + expires: Date.now() + remainingTtl, + }); + entries.set(hash, expiresAt); + } + return null; + } + + private sortedSet(key: string) { + let set = this.sortedSets.get(key); + if (!set) { + set = new Map(); + this.sortedSets.set(key, set); + } + return set; + } +} + +const options = { + name: "test", + maxRequests: 4, + period: 1_000, + lookback: 500, + ttl: 1_000, +}; + +describe("AdaptiveCache", () => { + test("shares quota and cached responses between instances", async () => { + const redis = new FakeRedis(); + const firstReplica = new AdaptiveCache(options, redis); + const restartedReplica = new AdaptiveCache(options, redis); + const upstream = mock( + async () => + new Response("first", { + status: 201, + statusText: "Created", + headers: { "x-cache-test": "yes" }, + }), + ); + const url = "https://example.com/data?secret=do-not-leak"; + + await firstReplica.fetch(url, undefined, upstream); + const response = await restartedReplica.fetch(url, undefined, upstream); + + expect(upstream).toHaveBeenCalledTimes(1); + expect(response.status).toBe(201); + expect(response.statusText).toBe("Created"); + expect(response.headers.get("x-cache-test")).toBe("yes"); + expect(await response.text()).toBe("first"); + expect(redis.commands.flatMap(({ args }) => args).join(" ")).not.toContain( + "do-not-leak", + ); + expect(await restartedReplica.debugStats()).toContain( + "HOT | recent=1 | projected=2.0/4", + ); + }); + + test("does not count or cache unsuccessful responses", async () => { + const redis = new FakeRedis(); + const cache = new AdaptiveCache(options, redis); + + await cache.fetch( + "https://example.com/failure", + undefined, + async () => new Response("failure", { status: 500 }), + ); + + expect(await cache.debugStats()).toContain("recent=0"); + expect(redis.strings.size).toBe(0); + }); + + test("fetches upstream without writing when Redis is unavailable", async () => { + const unavailable = { + get: async () => { + throw new Error("offline"); + }, + send: mock(async () => { + throw new Error("offline"); + }), + }; + const cache = new AdaptiveCache(options, unavailable); + const error = console.error; + console.error = mock(() => {}); + + try { + const response = await cache.fetch( + "https://example.com/data", + undefined, + async () => new Response("upstream"), + ); + expect(await response.text()).toBe("upstream"); + expect(unavailable.send).toHaveBeenCalledTimes(1); + } finally { + console.error = error; + } + }); + + test("expires response entries without losing request history", async () => { + const redis = new FakeRedis(); + const cache = new AdaptiveCache({ ...options, ttl: 5 }, redis); + let calls = 0; + const upstream = async () => new Response(`${++calls}`); + + await cache.fetch("https://example.com/data", undefined, upstream); + await Bun.sleep(10); + const response = await cache.fetch( + "https://example.com/data", + undefined, + upstream, + ); + + expect(await response.text()).toBe("2"); + expect(calls).toBe(2); + expect(await cache.debugStats()).toContain("recent=2"); + }); +}); diff --git a/lib/adaptive-cache.ts b/lib/adaptive-cache.ts new file mode 100644 index 0000000..1197c7c --- /dev/null +++ b/lib/adaptive-cache.ts @@ -0,0 +1,222 @@ +import { redis } from "@/lib/db/redis"; + +interface AdaptiveCacheOptions { + /** Stable namespace shared by every replica. */ + name: string; + + /** 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; +} + +type RedisClient = { + get(key: string): Promise; + send(command: string, args: string[]): Promise; +}; + +type Fetcher = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +type CachedResponse = { + version: 1; + status: number; + statusText: string; + headers: [string, string][]; + body: string; +}; + +const STATS_SCRIPT = ` +redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", "(" .. ARGV[1]) +redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", ARGV[2]) +return { redis.call("ZCARD", KEYS[1]), redis.call("ZCARD", KEYS[2]) } +`; + +const STORE_SCRIPT = ` +redis.call("ZADD", KEYS[2], ARGV[1], ARGV[2]) +redis.call("PEXPIRE", KEYS[2], ARGV[3]) + +if tonumber(ARGV[5]) > 0 then + redis.call("SET", KEYS[1], ARGV[4], "PX", ARGV[5]) + redis.call("ZADD", KEYS[3], ARGV[6], ARGV[7]) + redis.call("PEXPIRE", KEYS[3], ARGV[8]) +end +`; + +export class AdaptiveCache { + private readonly lookback: number; + private readonly threshold: number; + private readonly key: (req: Request) => string; + private readonly prefix: string; + + constructor( + private readonly options: AdaptiveCacheOptions, + private readonly client: RedisClient | null = redis, + ) { + this.lookback = options.lookback ?? options.period / 2; + this.threshold = options.threshold ?? 0.5; + this.key = options.key ?? ((req) => req.url); + this.prefix = `adaptive-cache:v1:${options.name}`; + } + + async fetch( + input: RequestInfo | URL, + init?: RequestInit, + fetcher: Fetcher = fetch, + ): Promise { + const req = input instanceof Request ? input : new Request(input, init); + const startedAt = Date.now(); + + if (!this.client) return fetcher(req); + + const hash = await this.hash(this.key(req)); + const responseKey = `${this.prefix}:response:${hash}`; + + try { + const { requests } = await this.stats(startedAt); + + if (this.shouldCache(requests)) { + const cached = await this.client.get(responseKey); + if (cached) { + try { + return this.deserialize(cached); + } catch (error) { + console.error("Invalid AdaptiveCache response in Redis", error); + await this.client.send("DEL", [responseKey]); + } + } + } + } catch (error) { + console.error("AdaptiveCache Redis read failed", error); + return fetcher(req); + } + + const res = await fetcher(req); + + if (res.ok) { + const expiresAt = startedAt + this.options.ttl; + const remainingTtl = Math.max(0, expiresAt - Date.now()); + let cached = ""; + + try { + cached = await this.serialize(res.clone()); + } catch (error) { + console.error("AdaptiveCache response serialization failed", error); + } + + try { + await this.client.send("EVAL", [ + STORE_SCRIPT, + "3", + responseKey, + `${this.prefix}:requests`, + `${this.prefix}:entries`, + `${startedAt}`, + `${startedAt}:${crypto.randomUUID()}`, + `${this.lookback}`, + cached, + `${cached ? remainingTtl : 0}`, + `${expiresAt}`, + hash, + `${this.options.ttl}`, + ]); + } catch (error) { + console.error("AdaptiveCache Redis write failed", error); + } + } + + return res; + } + + async debugStats() { + if (!this.client) + throw new Error("Redis is not available in this environment."); + + const { requests, entries } = await this.stats(Date.now()); + const projected = requests * (this.options.period / this.lookback); + + return [ + `AdaptiveCache`, + this.shouldCache(requests) ? "HOT" : "COLD", + `recent=${requests}`, + `projected=${projected.toFixed(1)}/${this.options.maxRequests}`, + `usage=${((projected / this.options.maxRequests) * 100).toFixed(0)}%`, + `entries=${entries}`, + ].join(" | "); + } + + private shouldCache(requests: number) { + const projected = requests * (this.options.period / this.lookback); + return projected >= this.options.maxRequests * this.threshold; + } + + private async stats(now: number) { + const result = await this.client!.send("EVAL", [ + STATS_SCRIPT, + "2", + `${this.prefix}:requests`, + `${this.prefix}:entries`, + `${now - this.lookback}`, + `${now}`, + ]); + + if (!Array.isArray(result) || result.length !== 2) + throw new Error("Unexpected AdaptiveCache stats response from Redis."); + + return { requests: Number(result[0]), entries: Number(result[1]) }; + } + + private async hash(value: string) { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + } + + private async serialize(response: Response) { + const cached: CachedResponse = { + version: 1, + status: response.status, + statusText: response.statusText, + headers: Array.from(response.headers.entries()), + body: Buffer.from(await response.arrayBuffer()).toString("base64"), + }; + return JSON.stringify(cached); + } + + private deserialize(value: string) { + const cached = JSON.parse(value) as CachedResponse; + if (cached.version !== 1) throw new Error("Unsupported cache version."); + + return new Response(Buffer.from(cached.body, "base64"), { + status: cached.status, + statusText: cached.statusText, + headers: cached.headers, + }); + } +} + +export const youtubeCache = new AdaptiveCache({ + name: "youtube", + maxRequests: 100, // YouTube search.list requests/day + period: 24 * 60 * 60_000, + ttl: 15 * 60_000, +}); diff --git a/lib/api.ts b/lib/api.ts index fe4530b..d4701c1 100755 --- a/lib/api.ts +++ b/lib/api.ts @@ -180,8 +180,10 @@ export async function toggleCheck(submissionId: string) { .where(eq(submissions.id, submissionId)); revalidatePath("/artifact/admin"); revalidatePath("/artifact"); + revalidatePath("/donate/admin"); sse.artifact.pub("update", { type: "toggleCheck" }); + sse.donate.pub("update", null); await actionLog(`Toggled an artifact submission check mark`); } diff --git a/lib/db/index.ts b/lib/db/index.ts index c4c5c5a..9c5a5c8 100755 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,4 +1,10 @@ import { drizzle } from "drizzle-orm/neon-http"; import * as schema from "./schema"; +import { withReplicas } from "drizzle-orm/pg-core"; -export const db = drizzle(process.env.DATABASE_URL!, { schema }); +export const db = process.env.REPLICA_DATABASE_URL + ? withReplicas(drizzle(process.env.DATABASE_URL!, { schema }), [ + drizzle(process.env.REPLICA_DATABASE_URL!, { schema }), + drizzle(process.env.REPLICA_DATABASE_URL!, { schema }), + ]) + : drizzle(process.env.DATABASE_URL!, { schema }); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index bd612ca..76a16cd 100755 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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(), }); @@ -301,6 +303,7 @@ export const tierlistVersions = tierlist.table("versions", { }), order: integer().notNull(), placements: jsonb().notNull().$type<{ [x: string]: string[] }>().default({}), + snapshot: jsonb().$type | null>().default(null), }); export const tierlistStates = tierlist.table("states", { @@ -347,6 +350,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 +363,23 @@ 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"), +}); + +export type TTrackingKey = string & { __brand: "tracking_key" }; +export type TAccessKey = string & { __brand: "access_key" }; +export type TUploadKey = string & { __brand: "upload_key" }; + +export const slipSync = schDonate.table("sync", { + trackingKey: text().primaryKey().$defaultFn(uuidv7).$type(), + accessKey: text().notNull().$defaultFn(uuidv7).$type(), + uploadKey: text().notNull().$defaultFn(uuidv7).$type(), + created: timestamp("created_at").defaultNow().notNull(), + + data: bytea(), + name: text(), + type: text(), + size: text(), }); //#endregion diff --git a/lib/db/sse-endpoints.ts b/lib/db/sse-endpoints.ts index bb3b70d..069fc65 100644 --- a/lib/db/sse-endpoints.ts +++ b/lib/db/sse-endpoints.ts @@ -1,7 +1,7 @@ import z from "zod/v4"; import type { YoutubeLiveInfo } from "@/app/api/live/route"; import { sseEndpoint, sseEndpointMap } from "./redis"; -import type { auditLog, tierlistStates } from "./schema"; +import type { auditLog, tierlistStates, TTrackingKey } from "./schema"; export const sse = sseEndpointMap({ // Artifact Admin Listener @@ -48,11 +48,21 @@ export const sse = sseEndpointMap({ active: { version: z.string(), live: z.custom(), + refresh: z.string().nullable(), + announcement: z.object({ + severity: z.enum(["info", "success", "warning", "error"]), + message: z.string(), + prefix: z.string().default("/"), + }), }, // Admin Live Log log: { update: z.custom(), }, + slip_sync: { + complete: z.custom(), + connected: z.custom(), + }, }); export const adminSseList: (keyof typeof sse)[] = ["log"]; export function tlSse(list: T) { diff --git a/lib/promptpay.test.ts b/lib/promptpay.test.ts new file mode 100644 index 0000000..b727d7b --- /dev/null +++ b/lib/promptpay.test.ts @@ -0,0 +1,1047 @@ +import { describe, expect, test } from "bun:test"; +import { + crc16, + type DataObject, + decodeEMVCo, + decodePromptPay, + encodeEMVCo, + encodePromptPay, + formatCRC, + generatePromptPayPayload, + normalizePromptPayIdentifier, + parseEMVCo, + parsePromptPayPayload, + parseTLV, + serializeTLV, + verifyCRC, +} from "./promptpay"; + +const encoder = new TextEncoder(); +const example = + "00020101021129370016A000000677010111021311111111111115802TH530376463047B5A"; + +function withCRC(payload: string): string { + const body = `${payload}6304`; + return `${body}${formatCRC(crc16(encoder.encode(body)))}`; +} + +describe("serializeTLV", () => { + test("sorts tags numerically without mutating the input", () => { + const objects: DataObject[] = [ + { type: "primitive", tag: 58, value: "TH" }, + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 53, value: "764" }, + ]; + const snapshot = structuredClone(objects); + + expect(serializeTLV(objects)).toBe("00020153037645802TH"); + expect(objects).toEqual(snapshot); + }); + + test("sorts tags inside templates", () => { + expect( + serializeTLV([ + { + type: "template", + tag: 29, + value: [ + { type: "primitive", tag: 3, value: "wallet" }, + { type: "primitive", tag: 0, value: "aid" }, + ], + }, + ]), + ).toBe("29170003aid0306wallet"); + }); + + test("serializes primitive data objects", () => { + expect( + serializeTLV([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + ]), + ).toBe("0002015802TH"); + }); + + test("pads single-digit tags and lengths", () => { + expect(serializeTLV([{ type: "primitive", tag: 7, value: "x" }])).toBe( + "0701x", + ); + }); + + test("serializes nested templates", () => { + const objects: DataObject[] = [ + { + type: "template", + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "1111111111111" }, + ], + }, + ]; + + expect(serializeTLV(objects)).toBe( + "29370016A00000067701011102131111111111111", + ); + }); + + test("serializes empty input and empty values", () => { + expect(serializeTLV([])).toBe(""); + expect(serializeTLV([{ type: "primitive", tag: 99, value: "" }])).toBe( + "9900", + ); + }); + + test("accepts the maximum tag and value length", () => { + const value = "x".repeat(99); + expect(serializeTLV([{ type: "primitive", tag: 99, value }])).toBe( + `9999${value}`, + ); + }); + + test.each([-1, 100])("rejects out-of-range tag %d", (tag) => { + expect(() => + serializeTLV([{ type: "primitive", tag, value: "x" }]), + ).toThrow("Tag 0 must be 00-99"); + }); + + test("reports the index of an invalid tag", () => { + expect(() => + serializeTLV([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 101, value: "x" }, + ]), + ).toThrow("Tag 1 must be 00-99"); + }); + + test("rejects values longer than the two-digit length field", () => { + expect(() => + serializeTLV([{ type: "primitive", tag: 0, value: "x".repeat(100) }]), + ).toThrow("Value 0 exceeds TLV length limit"); + }); + + test("rejects templates whose serialized value is too long", () => { + expect(() => + serializeTLV([ + { + type: "template", + tag: 29, + value: [{ type: "primitive", tag: 0, value: "x".repeat(96) }], + }, + ]), + ).toThrow("Value 0 exceeds TLV length limit"); + }); +}); + +describe("parseTLV", () => { + test("parses multiple primitive data objects", () => { + expect(parseTLV("00020153037645802TH")).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 53, value: "764" }, + { type: "primitive", tag: 58, value: "TH" }, + ]); + }); + + test("parses empty payloads and zero-length values", () => { + expect(parseTLV("")).toEqual([]); + expect(parseTLV("9900")).toEqual([ + { type: "primitive", tag: 99, value: "" }, + ]); + }); + + test("preserves values verbatim", () => { + expect(parseTLV("1006aB 1!?")).toEqual([ + { type: "primitive", tag: 10, value: "aB 1!?" }, + ]); + }); + + test.each(["0", "000", "0001x00"])( + "rejects an incomplete header in %p", + (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: incomplete headers at index/, + ); + }, + ); + + test.each(["AA00", "0A00", " 100"])( + "rejects invalid tags in %p", + (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid tag/, + ); + }, + ); + + test.each(["00AA", "000A", "00 1"])( + "rejects invalid lengths in %p", + (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid length/, + ); + }, + ); + + test("rejects a value shorter than its declared length", () => { + expect(() => parseTLV("0005abc")).toThrow( + "Malformed TLV payload: expected value length 5 at index 4, but reached end of string", + ); + }); + + test("reports malformed trailing data after valid objects", () => { + expect(() => parseTLV("000201x")).toThrow( + "Malformed TLV payload: incomplete headers at index 6", + ); + }); + + test("round-trips serialized primitive objects", () => { + const objects: DataObject[] = [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 1, value: "12" }, + { type: "primitive", tag: 54, value: "125.50" }, + ]; + + expect(parseTLV(serializeTLV(objects))).toEqual(objects); + }); +}); + +describe("parseEMVCo", () => { + test("parses merchant account information as a template", () => { + expect(parseEMVCo(example.slice(0, -8))).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 1, value: "11" }, + { + type: "template", + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "1111111111111" }, + ], + }, + { type: "primitive", tag: 58, value: "TH" }, + { type: "primitive", tag: 53, value: "764" }, + ]); + }); + + test("leaves non-template tags primitive", () => { + expect(parseEMVCo("0002015303764")).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 53, value: "764" }, + ]); + }); + + test("rejects malformed TLV inside a template", () => { + expect(() => parseEMVCo("29050005x")).toThrow(/Malformed TLV payload/); + }); +}); + +describe("decodeEMVCo", () => { + test("decodes the example payload", () => { + expect(decodeEMVCo(parseEMVCo(example.slice(0, -8)))).toEqual({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "11", + paymentNetworkSpecific: [ + { + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "1111111111111" }, + ], + }, + ], + transactionCurrency: "764", + countryCode: "TH", + }); + }); + + test("decodes optional amount and dynamic initiation method", () => { + const payload = "00020101021253037645406125.505802TH"; + expect(decodeEMVCo(parseEMVCo(payload))).toEqual({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "12", + paymentNetworkSpecific: [], + transactionCurrency: "764", + transactionAmount: "125.50", + countryCode: "TH", + }); + }); + + test("decodes an optional merchant category code", () => { + expect( + decodeEMVCo([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 52, value: "0000" }, + { type: "primitive", tag: 58, value: "TH" }, + ]), + ).toEqual({ + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + merchantCategoryCode: "0000", + countryCode: "TH", + }); + }); + + test("allows all optional fields to be absent", () => { + expect( + decodeEMVCo([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + ]), + ).toEqual({ + payloadFormatIndicator: "01", + countryCode: "TH", + paymentNetworkSpecific: [], + }); + }); + + test("preserves unknown tags", () => { + expect( + decodeEMVCo([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + { type: "primitive", tag: 59, value: "ignored" }, + ]), + ).toEqual({ + payloadFormatIndicator: "01", + countryCode: "TH", + paymentNetworkSpecific: [], + additionalData: [{ type: "primitive", tag: 59, value: "ignored" }], + }); + }); + + test.each([ + { + name: "missing payload format indicator", + objects: [{ type: "primitive", tag: 58, value: "TH" }], + }, + { + name: "unsupported payload format indicator", + objects: [ + { type: "primitive", tag: 0, value: "02" }, + { type: "primitive", tag: 58, value: "TH" }, + ], + }, + { + name: "unsupported initiation method", + objects: [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 1, value: "13" }, + { type: "primitive", tag: 58, value: "TH" }, + ], + }, + { + name: "invalid currency length", + objects: [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 53, value: "76" }, + { type: "primitive", tag: 58, value: "TH" }, + ], + }, + { + name: "missing country code", + objects: [{ type: "primitive", tag: 0, value: "01" }], + }, + { + name: "invalid country code length", + objects: [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "THA" }, + ], + }, + ] satisfies { name: string; objects: DataObject[] }[])( + "rejects $name", + ({ objects }) => { + expect(() => decodeEMVCo(objects)).toThrow(); + }, + ); +}); + +describe("encodeEMVCo", () => { + test("encodes a complete payload in EMVCo tag order", () => { + expect( + encodeEMVCo({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "12", + paymentNetworkSpecific: [ + { + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "0812345678" }, + ], + }, + ], + merchantCategoryCode: "0000", + transactionCurrency: "764", + transactionAmount: "125.50", + countryCode: "TH", + }), + ).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 1, value: "12" }, + { + type: "template", + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "0812345678" }, + ], + }, + { type: "primitive", tag: 52, value: "0000" }, + { type: "primitive", tag: 53, value: "764" }, + { type: "primitive", tag: 54, value: "125.50" }, + { type: "primitive", tag: 58, value: "TH" }, + ]); + }); + + test("encodes the minimal valid payload", () => { + expect( + encodeEMVCo({ + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + countryCode: "TH", + }), + ).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + ]); + }); + + test("preserves the order and contents of payment network templates", () => { + const first: DataObject[] = [ + { type: "primitive", tag: 0, value: "network-one" }, + ]; + const second: DataObject[] = [ + { type: "primitive", tag: 0, value: "network-two" }, + { + type: "template", + tag: 80, + value: [{ type: "primitive", tag: 1, value: "nested" }], + }, + ]; + + expect( + encodeEMVCo({ + payloadFormatIndicator: "01", + paymentNetworkSpecific: [ + { tag: 26, value: first }, + { tag: 51, value: second }, + ], + countryCode: "TH", + }), + ).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "template", tag: 26, value: first }, + { type: "template", tag: 51, value: second }, + { type: "primitive", tag: 58, value: "TH" }, + ]); + }); + + test("produces data objects that serialize and parse losslessly", () => { + const encoded = encodeEMVCo({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "11", + paymentNetworkSpecific: [ + { + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "1111111111111" }, + ], + }, + ], + transactionCurrency: "764", + countryCode: "TH", + }); + + expect(parseEMVCo(serializeTLV(encoded))).toEqual(encoded); + }); + + test("round-trips through decodeEMVCo", () => { + const data = { + payloadFormatIndicator: "01" as const, + pointOfInitiationMethod: "12" as const, + paymentNetworkSpecific: [ + { + tag: 29, + value: [ + { type: "primitive" as const, tag: 0, value: "A000000677010111" }, + { type: "primitive" as const, tag: 2, value: "0812345678" }, + ], + }, + ], + transactionCurrency: "764", + transactionAmount: "99.95", + countryCode: "TH", + }; + + expect(decodeEMVCo(encodeEMVCo(data))).toEqual(data); + }); + + test("does not mutate its input", () => { + const data = { + payloadFormatIndicator: "01" as const, + paymentNetworkSpecific: [ + { + tag: 29, + value: [{ type: "primitive" as const, tag: 0, value: "aid" }], + }, + ], + countryCode: "TH", + }; + const snapshot = structuredClone(data); + + encodeEMVCo(data); + + expect(data).toEqual(snapshot); + }); + + test("retains additional data through decoding and encoding", () => { + const objects: DataObject[] = [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + { type: "primitive", tag: 59, value: "MERCHANT" }, + { type: "primitive", tag: 60, value: "BANGKOK" }, + ]; + + expect(encodeEMVCo(decodeEMVCo(objects))).toEqual(objects); + }); + + test.each([ + { + name: "missing payload format indicator", + data: { paymentNetworkSpecific: [], countryCode: "TH" }, + }, + { + name: "unsupported payload format indicator", + data: { + payloadFormatIndicator: "02", + paymentNetworkSpecific: [], + countryCode: "TH", + }, + }, + { + name: "unsupported initiation method", + data: { + payloadFormatIndicator: "01", + pointOfInitiationMethod: "13", + paymentNetworkSpecific: [], + countryCode: "TH", + }, + }, + { + name: "missing payment network list", + data: { payloadFormatIndicator: "01", countryCode: "TH" }, + }, + { + name: "invalid merchant category code length", + data: { + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + merchantCategoryCode: "000", + countryCode: "TH", + }, + }, + { + name: "invalid currency length", + data: { + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + transactionCurrency: "76", + countryCode: "TH", + }, + }, + { + name: "missing country code", + data: { payloadFormatIndicator: "01", paymentNetworkSpecific: [] }, + }, + { + name: "invalid country code length", + data: { + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + countryCode: "THA", + }, + }, + ])("rejects $name", ({ data }) => { + expect(() => encodeEMVCo(data as never)).toThrow(); + }); +}); + +describe("decodePromptPay", () => { + const aid: DataObject = { + type: "primitive", + tag: 0, + value: "A000000677010111", + }; + + test.each([ + { tag: 1, type: "mobile", value: "0066812345678" }, + { tag: 2, type: "nationalId", value: "1234567890123" }, + { tag: 3, type: "ewallet", value: "123456789012345" }, + { tag: 4, type: "bankAccount", value: "1234567890" }, + ] as const)("decodes a $type identifier", ({ tag, type, value }) => { + expect(decodePromptPay([aid, { type: "primitive", tag, value }])).toEqual({ + aid: "A000000677010111", + identifier: { type, value }, + }); + }); + + test("decodes merchant account information parsed from an EMVCo payload", () => { + const objects = parseEMVCo("29370016A00000067701011102131111111111111"); + const merchantAccount = objects[0]; + + expect(merchantAccount?.type).toBe("template"); + if (merchantAccount?.type !== "template") return; + + expect(decodePromptPay(merchantAccount.value)).toEqual({ + aid: "A000000677010111", + identifier: { type: "nationalId", value: "1111111111111" }, + }); + }); + + test("rejects multiple recognized identifiers", () => { + expect(() => + decodePromptPay([ + aid, + { type: "primitive", tag: 4, value: "1234567890" }, + { type: "primitive", tag: 2, value: "1234567890123" }, + ]), + ).toThrow("Expected exactly one PromptPay identifier."); + }); + + test("ignores unknown tags before a recognized identifier", () => { + expect( + decodePromptPay([ + { type: "primitive", tag: 99, value: "ignored" }, + aid, + { type: "primitive", tag: 2, value: "1234567890123" }, + ]), + ).toEqual({ + aid: "A000000677010111", + identifier: { type: "nationalId", value: "1234567890123" }, + }); + }); + + test("ignores template objects using identifier tags", () => { + expect( + decodePromptPay([ + aid, + { + type: "template", + tag: 1, + value: [{ type: "primitive", tag: 0, value: "ignored" }], + }, + { type: "primitive", tag: 3, value: "123456789012345" }, + ]), + ).toEqual({ + aid: "A000000677010111", + identifier: { type: "ewallet", value: "123456789012345" }, + }); + }); + + test("accepts a bank account at its maximum length", () => { + const value = "1".repeat(43); + expect( + decodePromptPay([aid, { type: "primitive", tag: 4, value }]).identifier, + ).toEqual({ type: "bankAccount", value }); + }); + + test.each([ + { tag: 1, value: "1".repeat(12), name: "short mobile" }, + { tag: 1, value: "1".repeat(14), name: "long mobile" }, + { tag: 2, value: "1".repeat(12), name: "short national ID" }, + { tag: 2, value: "1".repeat(14), name: "long national ID" }, + { tag: 3, value: "1".repeat(14), name: "short e-wallet ID" }, + { tag: 3, value: "1".repeat(16), name: "long e-wallet ID" }, + { tag: 4, value: "1".repeat(44), name: "long bank account" }, + ])("rejects a $name", ({ tag, value }) => { + expect(() => + decodePromptPay([aid, { type: "primitive", tag, value }]), + ).toThrow(); + }); + + test("rejects input without a recognized identifier", () => { + expect(() => decodePromptPay([aid])).toThrow( + "Expected exactly one PromptPay identifier.", + ); + expect(() => + decodePromptPay([aid, { type: "primitive", tag: 5, value: "unknown" }]), + ).toThrow("Expected exactly one PromptPay identifier."); + }); + + test("rejects a missing or incorrect AID", () => { + const identifier: DataObject = { + type: "primitive", + tag: 2, + value: "1234567890123", + }; + + expect(() => decodePromptPay([identifier])).toThrow(); + expect(() => + decodePromptPay([ + { type: "primitive", tag: 0, value: "A000000000000000" }, + identifier, + ]), + ).toThrow(); + }); + + test("rejects a template AID", () => { + expect(() => + decodePromptPay([ + { type: "template", tag: 0, value: [] }, + { type: "primitive", tag: 1, value: "0066812345678" }, + ]), + ).toThrow(); + }); +}); + +describe("encodePromptPay", () => { + test.each([ + { type: "mobile", tag: 1, value: "0066812345678" }, + { type: "nationalId", tag: 2, value: "1234567890123" }, + { type: "ewallet", tag: 3, value: "123456789012345" }, + ] as const)( + "encodes a canonical $type identifier", + ({ type, tag, value }) => { + expect(encodePromptPay({ type, value })).toEqual([ + { + type: "primitive", + tag: 0, + value: "A000000677010111", + }, + { type: "primitive", tag, value }, + ]); + }, + ); + + test("rejects noncanonical identifier values", () => { + expect(() => + encodePromptPay({ type: "mobile", value: "0812345678" }), + ).toThrow(); + expect(() => + encodePromptPay({ type: "nationalId", value: "123-456" }), + ).toThrow(); + expect(() => + encodePromptPay({ type: "ewallet", value: "A".repeat(15) }), + ).toThrow(); + }); + + test("does not encode the reserved bank account identifier", () => { + expect(() => + encodePromptPay({ type: "bankAccount", value: "1234567890" } as never), + ).toThrow("reserved for future use"); + }); +}); + +describe("normalizePromptPayIdentifier", () => { + test.each([ + ["0801234567", "0066801234567"], + ["080-123-4567", "0066801234567"], + ["+66-89-123-4567", "0066891234567"], + ["66 89 123 4567", "0066891234567"], + ["0066812345678", "0066812345678"], + ])("normalizes mobile %s", (value, expected) => { + expect(normalizePromptPayIdentifier({ type: "mobile", value })).toEqual({ + type: "mobile", + value: expected, + }); + }); + + test("normalizes formatted national IDs", () => { + expect( + normalizePromptPayIdentifier({ + type: "nationalId", + value: "1-2345-67890-12-3", + }), + ).toEqual({ type: "nationalId", value: "1234567890123" }); + }); + + test("normalizes formatted e-wallet IDs", () => { + expect( + normalizePromptPayIdentifier({ + type: "ewallet", + value: "012 345 678 901 234", + }), + ).toEqual({ type: "ewallet", value: "012345678901234" }); + }); + + test.each([ + { type: "mobile", value: "08123abc", name: "mobile with letters" }, + { type: "mobile", value: "081234567", name: "short mobile" }, + { + type: "nationalId", + value: "123456789012A", + name: "national ID with letters", + }, + { + type: "nationalId", + value: "123456789012", + name: "short national ID", + }, + { + type: "ewallet", + value: "12345678901234", + name: "short e-wallet ID", + }, + ] as const)("rejects a $name", ({ type, value }) => { + expect(() => normalizePromptPayIdentifier({ type, value })).toThrow(); + }); +}); + +describe("generatePromptPayPayload", () => { + test.each([ + { + name: "local mobile number", + options: { + identifier: { type: "mobile" as const, value: "0801234567" }, + }, + expected: + "00020101021129370016A0000006770101110113006680123456753037645802TH6304BE2B", + }, + { + name: "formatted international mobile number", + options: { + identifier: { type: "mobile" as const, value: "+66-89-123-4567" }, + }, + expected: + "00020101021129370016A0000006770101110113006689123456753037645802TH6304F67D", + }, + { + name: "national ID", + options: { + identifier: { + type: "nationalId" as const, + value: "1-1111-11111-11-1", + }, + }, + expected: + "00020101021129370016A0000006770101110213111111111111153037645802TH6304A4E6", + }, + { + name: "tax ID", + options: { + identifier: { + type: "nationalId" as const, + value: "0123456789012", + }, + }, + expected: + "00020101021129370016A0000006770101110213012345678901253037645802TH6304D301", + }, + { + name: "e-wallet ID", + options: { + identifier: { + type: "ewallet" as const, + value: "012345678901234", + }, + }, + expected: + "00020101021129390016A000000677010111031501234567890123453037645802TH6304483D", + }, + { + name: "dynamic amount", + options: { + identifier: { type: "mobile" as const, value: "000-000-0000" }, + amount: 4.22, + }, + expected: + "00020101021229370016A00000067701011101130066000000000530376454044.225802TH63049A72", + }, + ])("matches the sorted $name vector", ({ options, expected }) => { + expect(generatePromptPayPayload(options)).toBe(expected); + }); + + test.each([ + ["4", "4.00"], + ["10", "10.00"], + [" 10 ", "10.00"], + ["4.2", "4.20"], + ["4.22", "4.22"], + [4, "4.00"], + [4.2, "4.20"], + ] as const)("formats amount %p as %s", (amount, expected) => { + const parsed = parsePromptPayPayload( + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + amount, + }), + ); + expect(parsed.emvco.transactionAmount).toBe(expected); + expect(parsed.emvco.pointOfInitiationMethod).toBe("12"); + }); + + test("emits an optional merchant category code", () => { + const parsed = parsePromptPayPayload( + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + merchantCategoryCode: "0000", + }), + ); + expect(parsed.emvco.merchantCategoryCode).toBe("0000"); + }); + + test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, "", "1.234"])( + "rejects invalid amount %p", + (amount) => { + expect(() => + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + amount, + }), + ).toThrow(); + }, + ); + + test("rejects invalid merchant category codes", () => { + expect(() => + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + merchantCategoryCode: "123", + }), + ).toThrow("must be 4 digits"); + }); +}); + +describe("parsePromptPayPayload", () => { + test("returns the raw, EMVCo, and PromptPay representations", () => { + const payload = generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + amount: "19.90", + }); + const parsed = parsePromptPayPayload(payload); + + expect(parsed.payload).toBe(payload); + expect(parsed.dataObjects.at(-1)).toEqual({ + type: "primitive", + tag: 63, + value: payload.slice(-4), + }); + expect(parsed.emvco).toMatchObject({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "12", + transactionCurrency: "764", + transactionAmount: "19.90", + countryCode: "TH", + }); + expect(parsed.promptPay).toEqual({ + aid: "A000000677010111", + identifier: { type: "mobile", value: "0066801234567" }, + }); + }); + + test("preserves unmodeled EMVCo data", () => { + const generated = generatePromptPayPayload({ + identifier: { type: "nationalId", value: "1234567890123" }, + }); + const payload = withCRC(`${generated.slice(0, -8)}5904TEST`); + + expect(parsePromptPayPayload(payload).emvco.additionalData).toEqual([ + { type: "primitive", tag: 59, value: "TEST" }, + ]); + }); + + test("rejects a corrupted checksum", () => { + const payload = generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + }); + expect(() => parsePromptPayPayload(`${payload.slice(0, -1)}0`)).toThrow( + "Invalid PromptPay payload CRC", + ); + }); + + test("rejects a payload without a PromptPay template", () => { + expect(() => + parsePromptPayPayload(withCRC("0002010102115802TH5303764")), + ).toThrow("exactly one PromptPay merchant account template"); + }); + + test("rejects a non-THB currency", () => { + const generated = generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + }); + const body = generated.slice(0, -8).replace("5303764", "5303840"); + expect(() => parsePromptPayPayload(withCRC(body))).toThrow( + "currency must be THB", + ); + }); + + test("rejects a non-Thai domestic country code", () => { + const generated = generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + }); + const body = generated.slice(0, -8).replace("5802TH", "5802US"); + expect(() => parsePromptPayPayload(withCRC(body))).toThrow( + "country code must be TH", + ); + }); +}); + +describe("crc16", () => { + test("matches the CRC-16/CCITT-FALSE check value", () => { + expect(crc16(encoder.encode("123456789"))).toBe(0x29b1); + }); + + test("returns the initial value for empty input", () => { + expect(crc16(new Uint8Array())).toBe(0xffff); + }); + + test("operates on raw bytes", () => { + expect(crc16(new Uint8Array([0x00, 0xff, 0x10, 0x80]))).toBe(0xd958); + }); + + test("generates the CRC from the PromptPay example", () => { + const withoutCrc = example.slice(0, -4); + expect(formatCRC(crc16(encoder.encode(withoutCrc)))).toBe( + example.slice(-4), + ); + }); +}); + +describe("formatCRC", () => { + test.each([ + [0x0000, "0000"], + [0x000a, "000A"], + [0x0123, "0123"], + [0xabcd, "ABCD"], + [0xffff, "FFFF"], + ])("formats %d as %s", (crc, expected) => { + expect(formatCRC(crc)).toBe(expected); + }); +}); + +describe("verifyCRC", () => { + test("verifies the PromptPay example", () => { + expect(verifyCRC(example)).toBeTrue(); + }); + + test("verifies generated payloads", () => { + expect(verifyCRC(withCRC("0002010102125802TH"))).toBeTrue(); + }); + + test("rejects payloads shorter than a CRC field", () => { + expect(verifyCRC("")).toBeFalse(); + expect(verifyCRC("6304ABC")).toBeFalse(); + }); + + test("rejects a missing or misplaced CRC tag", () => { + expect(verifyCRC("0002019904FFFF")).toBeFalse(); + expect(verifyCRC(`${example}00`)).toBeFalse(); + }); + + test("rejects a modified payload", () => { + const modified = `${example.slice(0, 4)}02${example.slice(6)}`; + expect(verifyCRC(modified)).toBeFalse(); + }); + + test("rejects a modified checksum", () => { + expect(verifyCRC(`${example.slice(0, -1)}B`)).toBeFalse(); + }); + + test("requires the canonical uppercase checksum", () => { + expect( + verifyCRC(example.slice(0, -4) + example.slice(-4).toLowerCase()), + ).toBe(false); + }); +}); diff --git a/lib/promptpay.ts b/lib/promptpay.ts new file mode 100644 index 0000000..53a4c2f --- /dev/null +++ b/lib/promptpay.ts @@ -0,0 +1,480 @@ +import { z } from "zod"; + +// #region Public types +export type DataObject = + | { + type: "primitive"; + tag: number; + value: string; + } + | { + type: "template"; + tag: number; + value: DataObject[]; + }; + +export type PaymentNetworkSpecific = { + tag: number; + value: DataObject[]; +}; + +export type EMVCoData = { + payloadFormatIndicator: "01"; + pointOfInitiationMethod?: "11" | "12"; + paymentNetworkSpecific: PaymentNetworkSpecific[]; + merchantCategoryCode?: string; + transactionCurrency?: string; + transactionAmount?: string; + countryCode: string; + additionalData?: DataObject[]; +}; + +export type PromptPayIdentifier = + | { type: "mobile"; value: string } + | { type: "nationalId"; value: string } + | { type: "ewallet"; value: string } + | { type: "bankAccount"; value: string }; + +export type EncodablePromptPayIdentifier = Exclude< + PromptPayIdentifier, + { type: "bankAccount" } +>; + +export type PromptPayData = { + aid: "A000000677010111"; + identifier: PromptPayIdentifier; +}; + +export type GeneratePromptPayOptions = { + identifier: EncodablePromptPayIdentifier; + amount?: number | string; + merchantCategoryCode?: string; +}; + +export type ParsedPromptPayPayload = { + payload: string; + dataObjects: DataObject[]; + emvco: EMVCoData; + promptPay: PromptPayData; +}; + +// #region TLV +function serializeTLV(objects: DataObject[]): string { + return [...objects] + .sort((left, right) => left.tag - right.tag) + .map((object, index) => { + if (!Number.isInteger(object.tag) || object.tag < 0 || object.tag > 99) + throw new Error(`Tag ${index} must be 00-99`); + + const value = + object.type === "primitive" ? object.value : serializeTLV(object.value); + + if (value.length > 99) + throw new Error(`Value ${index} exceeds TLV length limit`); + + const tag = object.tag.toString().padStart(2, "0"); + const length = value.length.toString().padStart(2, "0"); + return `${tag}${length}${value}`; + }) + .join(""); +} + +function parseTLV(payload: string): DataObject[] { + const objects: DataObject[] = []; + let index = 0; + + while (index < payload.length) { + if (index + 4 > payload.length) + throw new Error( + `Malformed TLV payload: incomplete headers at index ${index}`, + ); + + const tagString = payload.slice(index, index + 2); + if (!/^\d{2}$/.test(tagString)) + throw new Error( + `Malformed TLV payload: invalid tag "${tagString}" at index ${index}`, + ); + const tag = Number.parseInt(tagString, 10); + index += 2; + + const lengthString = payload.slice(index, index + 2); + if (!/^\d{2}$/.test(lengthString)) + throw new Error( + `Malformed TLV payload: invalid length "${lengthString}" at index ${index}`, + ); + const length = Number.parseInt(lengthString, 10); + index += 2; + + if (index + length > payload.length) + throw new Error( + `Malformed TLV payload: expected value length ${length} at index ${index}, but reached end of string`, + ); + + const value = payload.slice(index, index + length); + index += length; + objects.push({ type: "primitive", tag, value }); + } + + return objects; +} + +// #region Utilities +function primitive( + data: DataObject[] | undefined, + tag: number, +): string | undefined { + return data?.find( + (object): object is Extract => + object.tag === tag && object.type === "primitive", + )?.value; +} + +function optionalProperty(key: string, value: T | undefined) { + return value === undefined ? {} : { [key]: value }; +} + +// #region EMVCo +const TEMPLATE_TAGS = new Set([ + ...Array.from({ length: 50 }, (_, index) => index + 2), + 62, + 64, + ...Array.from({ length: 20 }, (_, index) => index + 80), +]); + +function parseEMVCo(payload: string): DataObject[] { + return parseTLV(payload).map((object) => + TEMPLATE_TAGS.has(object.tag) && object.type === "primitive" + ? { + type: "template", + tag: object.tag, + value: parseTLV(object.value), + } + : object, + ); +} + +const emvCoSchema = z.object({ + payloadFormatIndicator: z.literal("01"), + pointOfInitiationMethod: z.enum(["11", "12"]).optional(), + paymentNetworkSpecific: z.array( + z.object({ + tag: z.number().int().min(2).max(51), + value: z.custom((value) => Array.isArray(value)), + }), + ), + merchantCategoryCode: z.string().length(4).optional(), + transactionCurrency: z.string().length(3).optional(), + transactionAmount: z.string().optional(), + countryCode: z.string().length(2), + additionalData: z.array(z.custom()).optional(), +}); + +const MODELED_EMVCO_TAGS = new Set([0, 1, 52, 53, 54, 58]); + +function decodeEMVCo(objects: DataObject[]): EMVCoData { + const additionalData = objects.filter( + (object) => + !MODELED_EMVCO_TAGS.has(object.tag) && + !(object.tag >= 2 && object.tag <= 51), + ); + + return emvCoSchema.parse({ + payloadFormatIndicator: primitive(objects, 0), + ...optionalProperty("pointOfInitiationMethod", primitive(objects, 1)), + paymentNetworkSpecific: objects + .filter( + (object): object is Extract => + object.type === "template" && object.tag >= 2 && object.tag <= 51, + ) + .map(({ tag, value }) => ({ tag, value })), + ...optionalProperty("merchantCategoryCode", primitive(objects, 52)), + ...optionalProperty("transactionCurrency", primitive(objects, 53)), + ...optionalProperty("transactionAmount", primitive(objects, 54)), + countryCode: primitive(objects, 58), + ...(additionalData.length === 0 ? {} : { additionalData }), + }); +} + +function encodeEMVCo(data: EMVCoData): DataObject[] { + const parsed = emvCoSchema.parse(data); + const objects: DataObject[] = [ + { type: "primitive", tag: 0, value: parsed.payloadFormatIndicator }, + ]; + + if (parsed.pointOfInitiationMethod !== undefined) + objects.push({ + type: "primitive", + tag: 1, + value: parsed.pointOfInitiationMethod, + }); + + objects.push( + ...parsed.paymentNetworkSpecific.map( + ({ tag, value }): DataObject => ({ type: "template", tag, value }), + ), + ); + + for (const [tag, value] of [ + [52, parsed.merchantCategoryCode], + [53, parsed.transactionCurrency], + [54, parsed.transactionAmount], + [58, parsed.countryCode], + ] as const) { + if (value !== undefined) objects.push({ type: "primitive", tag, value }); + } + + objects.push(...(parsed.additionalData ?? [])); + return objects; +} + +// #region CRC +function crc16(data: Uint8Array): number { + let crc = 0xffff; + const polynomial = 0x1021; + + for (const byte of data) { + crc ^= byte << 8; + for (let bit = 0; bit < 8; bit++) + crc = + (crc & 0x8000) !== 0 + ? ((crc << 1) ^ polynomial) & 0xffff + : (crc << 1) & 0xffff; + } + + return crc; +} + +function formatCRC(crc: number): string { + return crc.toString(16).toUpperCase().padStart(4, "0"); +} + +function verifyCRC(payload: string): boolean { + if (payload.length < 8 || payload.slice(-8, -4) !== "6304") return false; + + const expected = payload.slice(-4); + const calculated = formatCRC( + crc16(new TextEncoder().encode(payload.slice(0, -4))), + ); + return calculated === expected; +} + +function appendCRC(payload: string): string { + const body = `${payload}6304`; + return `${body}${formatCRC(crc16(new TextEncoder().encode(body)))}`; +} + +// #region PromptPay +const PROMPTPAY_AID = "A000000677010111" as const; + +const promptPaySchema = z.object({ + aid: z.literal(PROMPTPAY_AID), + identifier: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("mobile"), + value: z.string().regex(/^\d{13}$/), + }), + z.object({ + type: z.literal("nationalId"), + value: z.string().regex(/^\d{13}$/), + }), + z.object({ + type: z.literal("ewallet"), + value: z.string().regex(/^\d{15}$/), + }), + z.object({ + type: z.literal("bankAccount"), + value: z.string().regex(/^\d{1,43}$/), + }), + ]), +}); + +const IDENTIFIER_TYPES = { + 1: "mobile", + 2: "nationalId", + 3: "ewallet", + 4: "bankAccount", +} as const; + +const IDENTIFIER_TAGS = { + mobile: 1, + nationalId: 2, + ewallet: 3, +} as const; + +function decodePromptPay(merchantAccount: DataObject[]): PromptPayData { + const identifiers = merchantAccount.filter( + (object): object is Extract => + object.type === "primitive" && object.tag in IDENTIFIER_TYPES, + ); + + if (identifiers.length !== 1) + throw new Error("Expected exactly one PromptPay identifier."); + + const identifier = identifiers[0]; + return promptPaySchema.parse({ + aid: primitive(merchantAccount, 0), + identifier: { + type: IDENTIFIER_TYPES[identifier.tag as keyof typeof IDENTIFIER_TYPES], + value: identifier.value, + }, + }); +} + +function encodePromptPay( + identifier: EncodablePromptPayIdentifier, +): DataObject[] { + const parsed = promptPaySchema.shape.identifier.parse(identifier); + if (parsed.type === "bankAccount") + throw new Error( + "PromptPay bank account encoding is reserved for future use.", + ); + + return [ + { type: "primitive", tag: 0, value: PROMPTPAY_AID }, + { + type: "primitive", + tag: IDENTIFIER_TAGS[parsed.type], + value: parsed.value, + }, + ]; +} + +function normalizePromptPayIdentifier( + identifier: EncodablePromptPayIdentifier, +): EncodablePromptPayIdentifier { + const value = identifier.value.trim(); + + if (identifier.type === "mobile") { + if (!/^\+?[\d\s()-]+$/.test(value)) + throw new Error("PromptPay mobile number contains invalid characters."); + + const digits = value.replace(/\D/g, ""); + const canonical = /^0\d{9}$/.test(digits) + ? `0066${digits.slice(1)}` + : /^66\d{9}$/.test(digits) + ? `00${digits}` + : digits; + + return promptPaySchema.shape.identifier.parse({ + type: "mobile", + value: canonical, + }) as EncodablePromptPayIdentifier; + } + + if (!/^[\d\s-]+$/.test(value)) + throw new Error( + `PromptPay ${identifier.type} contains invalid characters.`, + ); + + return promptPaySchema.shape.identifier.parse({ + type: identifier.type, + value: value.replace(/[\s-]/g, ""), + }) as EncodablePromptPayIdentifier; +} + +function formatAmount(amount: number | string): string { + let formatted = ""; + if (typeof amount === "number") { + if (Number.isFinite(amount)) formatted = amount.toFixed(2); + } else { + const value = amount.trim(); + if (/^\d+(?:\.\d{1,2})?$/.test(value)) { + const [integer, fraction = ""] = value.split("."); + formatted = `${integer}.${fraction.padEnd(2, "0")}`; + } + } + + if (!/^\d+\.\d{2}$/.test(formatted) || Number(formatted) <= 0) + throw new Error( + "PromptPay amount must be a positive number with at most 2 decimals.", + ); + if (formatted.length > 13) + throw new Error("PromptPay amount exceeds the EMVCo length limit."); + + return formatted; +} + +function generatePromptPayPayload(options: GeneratePromptPayOptions): string { + const identifier = normalizePromptPayIdentifier(options.identifier); + const amount = + options.amount === undefined ? undefined : formatAmount(options.amount); + + if ( + options.merchantCategoryCode !== undefined && + !/^\d{4}$/.test(options.merchantCategoryCode) + ) + throw new Error("PromptPay merchant category code must be 4 digits."); + + const objects: DataObject[] = [ + { type: "primitive", tag: 0, value: "01" }, + { + type: "primitive", + tag: 1, + value: amount === undefined ? "11" : "12", + }, + { type: "template", tag: 29, value: encodePromptPay(identifier) }, + ]; + + if (options.merchantCategoryCode !== undefined) + objects.push({ + type: "primitive", + tag: 52, + value: options.merchantCategoryCode, + }); + + objects.push( + { type: "primitive", tag: 53, value: "764" }, + { type: "primitive", tag: 58, value: "TH" }, + ); + if (amount !== undefined) + objects.push({ type: "primitive", tag: 54, value: amount }); + + return appendCRC(serializeTLV(objects)); +} + +function parsePromptPayPayload(payload: string): ParsedPromptPayPayload { + if (!verifyCRC(payload)) throw new Error("Invalid PromptPay payload CRC."); + + const dataObjects = parseEMVCo(payload); + const crc = dataObjects.at(-1); + if (crc?.type !== "primitive" || crc.tag !== 63) + throw new Error("PromptPay payload is missing its CRC data object."); + + const emvco = decodeEMVCo(dataObjects.slice(0, -1)); + if (emvco.transactionCurrency !== "764") + throw new Error("PromptPay transaction currency must be THB (764)."); + if (emvco.countryCode !== "TH") + throw new Error("Domestic PromptPay country code must be TH."); + + const promptPayTemplates = emvco.paymentNetworkSpecific.filter( + ({ tag }) => tag === 29, + ); + if (promptPayTemplates.length !== 1) + throw new Error( + "Expected exactly one PromptPay merchant account template.", + ); + + return { + payload, + dataObjects, + emvco, + promptPay: decodePromptPay(promptPayTemplates[0].value), + }; +} + +export { + appendCRC, + crc16, + decodeEMVCo, + decodePromptPay, + encodeEMVCo, + encodePromptPay, + formatCRC, + generatePromptPayPayload, + normalizePromptPayIdentifier, + parseEMVCo, + parsePromptPayPayload, + parseTLV, + serializeTLV, + verifyCRC, +}; diff --git a/lib/tierlist.ts b/lib/tierlist.ts new file mode 100644 index 0000000..3ee43ca --- /dev/null +++ b/lib/tierlist.ts @@ -0,0 +1,200 @@ +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { + characters, + tierlistBadges, + tierlistColumns, + tierlistTiers, + tierlistTypes, + tierlistVersions, + versions, +} from "@/lib/db/schema"; + +export type TierlistBadgeResolved = typeof tierlistBadges.$inferSelect & { + tier: string[]; +}; + +export type TierlistResolvedConfig = { + type: typeof tierlistTypes.$inferSelect; + version: typeof tierlistVersions.$inferSelect; + tiers: (typeof tierlistTiers.$inferSelect)[]; + columns: (typeof tierlistColumns.$inferSelect)[]; + badges: TierlistBadgeResolved[]; + chars: (typeof characters.$inferSelect)[]; +}; + +type DbLike = Pick; + +function normalizeSnapshot( + config: TierlistResolvedConfig, +): TierlistResolvedConfig { + return { + ...config, + version: { + ...config.version, + snapshot: null, + }, + }; +} + +async function resolveCharacterVersions(tx: DbLike, rootVersionId: string) { + const vers = await tx + .select({ id: versions.id, from: versions.from }) + .from(versions); + const ids: string[] = []; + let cur: string | null = rootVersionId; + + while (cur) { + ids.push(cur); + const found = vers.find((version) => version.id === cur); + cur = found?.from ?? null; + } + + return ids; +} + +async function buildLiveTierlistConfig( + tx: DbLike, + version: typeof tierlistVersions.$inferSelect, +) { + const [typeInfo] = await tx + .select() + .from(tierlistTypes) + .where(eq(tierlistTypes.id, version.type)); + if (!typeInfo) return null; + + const ids = await resolveCharacterVersions(tx, version.from); + const chars = + ids.length > 0 + ? await tx + .select() + .from(characters) + .where(inArray(characters.version, ids)) + .orderBy(characters.order) + : ([] as (typeof characters.$inferSelect)[]); + + const [tiers, columns, badgesList] = await Promise.all([ + tx.select().from(tierlistTiers).orderBy(tierlistTiers.order), + tx.select().from(tierlistColumns).orderBy(tierlistColumns.order), + tx + .select() + .from(tierlistBadges) + .orderBy(tierlistBadges.order, tierlistBadges.id) + .where( + or(isNull(tierlistBadges.type), eq(tierlistBadges.type, version.type)), + ), + ]); + + const badges = badgesList.map((badge) => ({ + ...badge, + tier: tiers + .filter((tier) => tier.badges?.includes(badge.id)) + .map((tier) => tier.id), + })); + + return normalizeSnapshot({ + type: typeInfo, + version, + tiers, + columns, + badges, + chars, + }); +} + +function shouldSnapshot(config: TierlistResolvedConfig) { + const deprecatedAt = parseDate(config.version.deprecates); + const olderThanMonth = + Number.isFinite(deprecatedAt) && + deprecatedAt <= Date.now() - 30 * 24 * 60 * 60 * 1000; + + const placed = new Set( + Object.values(config.version.placements) + .flat() + .map((entry) => entry.split("#")[0]), + ); + const allTiered = + config.chars.length > 0 && + config.chars.every((char) => placed.has(char.id)); + + return olderThanMonth || allTiered; +} + +function parseDate(value: string) { + const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!match) return Number.NaN; + + const [, day, month, year] = match; + const date = new Date(Number(year), Number(month) - 1, Number(day)); + + if ( + date.getFullYear() !== Number(year) || + date.getMonth() !== Number(month) - 1 || + date.getDate() !== Number(day) + ) { + return Number.NaN; + } + + return date.getTime(); +} + +async function maybeSnapshotVersion( + tx: DbLike, + version: typeof tierlistVersions.$inferSelect, + config: TierlistResolvedConfig, +) { + if (version.snapshot || !shouldSnapshot(config)) return config; + + await tx + .update(tierlistVersions) + .set({ snapshot: config }) + .where(eq(tierlistVersions.id, version.id)); + + return config; +} + +export async function getTierlistConfig(typeId: string, versionId: string) { + return db.transaction(async (tx) => { + const [version] = await tx + .select() + .from(tierlistVersions) + .where( + and( + eq(tierlistVersions.type, typeId), + eq(tierlistVersions.id, versionId), + ), + ); + if (!version) return null; + + if (version.snapshot) { + return normalizeSnapshot(version.snapshot as TierlistResolvedConfig); + } + + const config = await buildLiveTierlistConfig(tx, version); + if (!config) return null; + + return maybeSnapshotVersion(tx, version, config); + }); +} + +export async function syncTierlistSnapshots(typeId: string) { + return db.transaction(async (tx) => { + const versionList = await tx + .select() + .from(tierlistVersions) + .where(eq(tierlistVersions.type, typeId)) + .orderBy(tierlistVersions.order, tierlistVersions.id); + + for (const version of versionList) { + if (version.snapshot) continue; + + const config = await buildLiveTierlistConfig(tx, version); + if (!config || !shouldSnapshot(config)) continue; + + await tx + .update(tierlistVersions) + .set({ snapshot: config }) + .where(eq(tierlistVersions.id, version.id)); + } + }); +} diff --git a/lib/utils.ts b/lib/utils.ts index aad27d4..b61eddd 100755 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -72,3 +72,18 @@ export function parseSearchString( export function pausePass(time: number): (res: R) => Promise { return (res) => new Promise((r) => setTimeout(() => r(res), time)); } + +// taken from astral/api +export class ResponseNotOkError extends Error { + constructor( + readonly status: number, + readonly response: Response, + ) { + super(`RESPONSE_NOT_OK: Got status ${status}`); + } +} + +export function throwNotOk(res: Response) { + if (!res.ok) throw new ResponseNotOkError(res.status, res); + return res; +} diff --git a/next.config.ts b/next.config.ts index ec50802..6e00d45 100755 --- a/next.config.ts +++ b/next.config.ts @@ -50,7 +50,7 @@ export default { }, deploymentId: process.env.NEXT_DEPLOYMENT_ID, // reactCompiler: true, - allowedDevOrigins: ["astral:3000", "dev3000.astrxl.dev"], + allowedDevOrigins: ["astral:3000", "dev3000.astrxl.dev", "nyx"], // basePath: "/beta" output: "standalone", } satisfies NextConfig; diff --git a/package.json b/package.json index 8078d85..6e15ab7 100755 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "drizzle-orm": "^0.44.7", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "lucide-react": "^0.562.0", + "lucide-react": "^1.27.0", "motion": "^12.40.0", "next": "16.2.6", "next-themes": "^0.4.6", @@ -82,6 +82,7 @@ "react-dom": "19.2.6", "react-hook-form": "^7.68.0", "react-hook-text-overflow": "^1.0.1", + "react-qr-code": "^2.2.0", "react-resizable-panels": "^3.0.6", "react-use": "^17.6.0", "recharts": "2.15.4", diff --git a/util/img/traveler_cyro.webp b/util/img/traveler_cyro.webp new file mode 100644 index 0000000..1c84136 Binary files /dev/null and b/util/img/traveler_cyro.webp differ diff --git a/util/sync.ts b/util/sync.ts index 0cf274d..28e98fd 100755 --- a/util/sync.ts +++ b/util/sync.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { inArray } from "drizzle-orm"; +import { inArray, sql } from "drizzle-orm"; import { cdnify, checkCdnRefs } from "@/lib/api"; import { AmberElementMap } from "@/lib/const"; import { db } from "@/lib/db"; @@ -91,6 +91,20 @@ const extra: Avatar[] = [ route: "Dendro Traveler Boy", release: 1657659600, }, + { + id: "10000005-cyro", + rank: 5, + name: "Traveler Cyro", + element: "Ice", + weaponType: "WEAPON_SWORD_ONE_HAND", + region: "MAINACTOR", + specialProp: "FIGHT_PROP_ATTACK_PERCENT", + bodyType: "BOY", + icon: "custom/traveler_cyro.webp:image/webp", + birthday: [0, 0], + route: "Cyro Traveler Boy", + release: 1786496400, + }, ]; async function main() { @@ -104,7 +118,7 @@ async function main() { e.json(), ), ]); - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { // console.log("Deleting existing versions..."); // await tx.delete(versions); console.log("Creating new versions..."); @@ -209,7 +223,9 @@ async function main() { }) .onConflictDoNothing(); } - let i = 0; + let [{ maxOrder: i }] = await db + .select({ maxOrder: sql`MAX(${characters.order})` }) + .from(characters); for (const char of newChars) { i += 10; process.stdout.write( @@ -248,7 +264,7 @@ async function toFile(res: Response) { const customImageRegex = /^custom\/([a-z0-9_.-]+)(?::([a-z]+\/[a-z]+))/im; -main(); +if (import.meta.main) main(); export interface AvatarApiResponse { response: number; @@ -287,3 +303,5 @@ export interface ChangelogVersion { }; version: string; } + +export const syncAmber = main; diff --git a/vercel.json b/vercel.json index 49da728..2b6ae97 100644 --- a/vercel.json +++ b/vercel.json @@ -1,3 +1,3 @@ { "bunVersion": "1.x" -} \ No newline at end of file +}