Merge remote-tracking branch 'origin/main' into dev
Build and Push Image / push (push) Has been cancelled
Build and Push Image / build-frontend (push) Has been cancelled
Build and Push Image / build-backend (push) Has been cancelled

This commit is contained in:
Dreamgineer
2026-06-18 22:06:09 +07:00 Unverified
35 changed files with 809 additions and 561 deletions
+71 -65
View File
@@ -284,23 +284,23 @@ graph TB
## Real-Time System (SSE)
| Endpoint | Events | Clients |
|---|---|---|
| `artifact` | submit, toggleCheck, toggleLock, setLimit, wipe | Admin panel, artifact widget |
| `rubgram` | submit, paid, toggleCheck, toggleLock, setLimit, setFree, cancel, uploadSlip | Admin panel, rubgram widget |
| `donate` | ping, heartbeat, update, refresh | Donate widget, top widget, admin |
| `active` | version (deploy), live (YouTube status) | All pages |
| `log` | update (audit log) | Admin log viewer |
| `tl.{name}` | update_states, update_placements | Tierlist per-type |
| Endpoint | Events | Clients |
| ----------- | ---------------------------------------------------------------------------- | -------------------------------- |
| `artifact` | submit, toggleCheck, toggleLock, setLimit, wipe | Admin panel, artifact widget |
| `rubgram` | submit, paid, toggleCheck, toggleLock, setLimit, setFree, cancel, uploadSlip | Admin panel, rubgram widget |
| `donate` | ping, heartbeat, update, refresh | Donate widget, top widget, admin |
| `active` | version (deploy), live (YouTube status) | All pages |
| `log` | update (audit log) | Admin log viewer |
| `tl.{name}` | update_states, update_placements | Tierlist per-type |
## Embeddable Widgets (OBS Browser Source)
| Widget | Route | Description |
|---|---|---|
| Donation alert | `/widget/donate` | Full-screen animated popup with SFX + TTS |
| Top donor | `/widget/donate/top` | Compact real-time #1 donor bar |
| Artifact queue | `/widget/artifact-count` | Submission count / capacity |
| Rubgram queue | `/widget/rubgram-count` | Submission count / capacity |
| Widget | Route | Description |
| -------------- | ------------------------ | ----------------------------------------- |
| Donation alert | `/widget/donate` | Full-screen animated popup with SFX + TTS |
| Top donor | `/widget/donate/top` | Compact real-time #1 donor bar |
| Artifact queue | `/widget/artifact-count` | Submission count / capacity |
| Rubgram queue | `/widget/rubgram-count` | Submission count / capacity |
All widgets are SSE-driven, auto-updating, and designed for streaming overlays.
@@ -318,34 +318,36 @@ All widgets are SSE-driven, auto-updating, and designed for streaming overlays.
## External APIs
| API | Usage | Endpoint |
|---|---|---|
| Project Amber | Character and version data, synced every 14 days | gi.yatta.moe |
| Enka Network | Character showcase auto-fetch | enka.network |
| Astral/Enka Embed | Character card rendering | git.dgnr.us/astral/api |
| SlipOK | PromptPay slip verification | configurable |
| Sastify | TrueMoney voucher redemption | api.sastify.xyz |
| Google Gemini | TTS for donation announcements | gemini-2.5-flash-preview-tts |
| Discord API | OAuth2, guild member list, webhook pings | discord.com |
| YouTube API | Live stream status | googleapis.com |
| PostHog Cloud | Analytics and error tracking | us.i.posthog.com |
| API | Usage | Endpoint |
| ----------------- | ------------------------------------------------ | ---------------------------- |
| Project Amber | Character and version data, synced every 14 days | gi.yatta.moe |
| Enka Network | Character showcase auto-fetch | enka.network |
| Astral/Enka Embed | Character card rendering | git.dgnr.us/astral/api |
| SlipOK | PromptPay slip verification | configurable |
| Sastify | TrueMoney voucher redemption | api.sastify.xyz |
| Google Gemini | TTS for donation announcements | gemini-2.5-flash-preview-tts |
| Discord API | OAuth2, guild member list, webhook pings | discord.com |
| YouTube API | Live stream status | googleapis.com |
| PostHog Cloud | Analytics and error tracking | us.i.posthog.com |
## Database Schemas (PostgreSQL)
| Schema | Tables | Domain |
|---|---|---|
| `public` | characters, versions, settings, guides, cdn, auditLog, user, session, account, verification | Core data (characters, auth, CDN, settings) |
| `artifact` | submissions, cards, settings | Artifact review queue |
| `endgame` | submissions, sarchive, expired, slips, settings, discord, types | Rubgram service ordering |
| `tierlist` | types, tiers, columns, badges, versions, states | Tier rankings |
| `donate` | donations | Donation records |
| Schema | Tables | Domain |
| ---------- | ------------------------------------------------------------------------------------------- | ------------------------------------------- |
| `public` | characters, versions, settings, guides, cdn, auditLog, user, session, account, verification | Core data (characters, auth, CDN, settings) |
| `artifact` | submissions, cards, settings | Artifact review queue |
| `endgame` | submissions, sarchive, expired, slips, settings, discord, types | Rubgram service ordering |
| `tierlist` | types, tiers, columns, badges, versions, states | Tier rankings |
| `donate` | donations | Donation records |
## Contributing
### Prerequisites
- Bun, Docker & Docker Compose
### Setup
1. Clone the repo.
2. Copy `.env.example` to `.env` and fill in secrets (or use `dev/.env.development` for local defaults).
3. Start the dev environment:
@@ -357,32 +359,35 @@ All widgets are SSE-driven, auto-updating, and designed for streaming overlays.
5. Seeded admin account: `admin@dgnr.us` / `youshallnotpass` (set via `INITIAL_ADMIN_PWD`).
### Useful commands
| Command | What it does |
|---|---|
| `bun dev` | Start full dev environment |
| `bun ds` | Open devshell |
| `bun run lint` | oxlint |
| `bun run format` | oxfmt |
| `bun run build` | Production build (Turbopack) |
| `bun dr` | drizzle-kit (generate, push, studio) |
| `bun ba` | better-auth CLI |
| Command | What it does |
| ---------------- | ------------------------------------ |
| `bun dev` | Start full dev environment |
| `bun ds` | Open devshell |
| `bun run lint` | oxlint |
| `bun run format` | oxfmt |
| `bun run build` | Production build (Turbopack) |
| `bun dr` | drizzle-kit (generate, push, studio) |
| `bun ba` | better-auth CLI |
### Code style
- Linting: oxlint (with tailwind and tsgo plugins), oxfmt
- All row IDs are UUIDv7
- PostgreSQL schemas for domain separation (`public`, `artifact`, `endgame`, `tierlist`, `donate`)
- New features should follow existing patterns: SSE for real-time, Zod for validation, server actions for mutations
### Project structure
| Path | Contents |
|---|---|
| `app/(ui)/` | Public pages (donate, artifact, rubgram, tierlist, guide, admin, login) |
| `app/api/` | API routes (auth, payments, TTS, Amber sync, card rendering) |
| `app/widget/` | Embeddable OBS overlays |
| `app/sse/` | Server-Sent Events streaming endpoints |
| `backend/` | Standalone Bun backend (cron jobs, Discord webhooks, DB seeding) |
| `lib/db/` | Database schema, SSE endpoint definitions, Redis client |
| `components/` | Shared UI primitives (shadcn/ui) |
| Path | Contents |
| ------------- | ----------------------------------------------------------------------- |
| `app/(ui)/` | Public pages (donate, artifact, rubgram, tierlist, guide, admin, login) |
| `app/api/` | API routes (auth, payments, TTS, Amber sync, card rendering) |
| `app/widget/` | Embeddable OBS overlays |
| `app/sse/` | Server-Sent Events streaming endpoints |
| `backend/` | Standalone Bun backend (cron jobs, Discord webhooks, DB seeding) |
| `lib/db/` | Database schema, SSE endpoint definitions, Redis client |
| `components/` | Shared UI primitives (shadcn/ui) |
## Deployment
@@ -397,12 +402,13 @@ Push to `main` or `dev` triggers `.github/workflows/build.yml`:
### Production stack (Docker Swarm)
| Service | Replicas | Resources | Notes |
|---|---|---|---|
| Frontend | 2 | 1 CPU / 1 GB RAM | Healthcheck on `:3000`, autoscaling label |
| Backend | 1 | 1 CPU / 512 MB RAM | `NO_AUTH_CHECK=true` env, auto-rollback on failure |
| Service | Replicas | Resources | Notes |
| -------- | -------- | ------------------ | -------------------------------------------------- |
| Frontend | 2 | 1 CPU / 1 GB RAM | Healthcheck on `:3000`, autoscaling label |
| Backend | 1 | 1 CPU / 512 MB RAM | `NO_AUTH_CHECK=true` env, auto-rollback on failure |
Infrastructure dependencies:
- PostgreSQL database (managed externally)
- Redis instance for SSE pub/sub and caching
- Nginx reverse proxy with SSE optimizations (buffering off, 200s read timeout)
@@ -412,17 +418,17 @@ Infrastructure dependencies:
Required variables (~30 total, see `.env.example`):
| Category | Variables |
|---|---|
| Database | `DATABASE_URL`, `REDIS_URL` |
| Auth | `BETTER_AUTH_SECRET` |
| Rubgram payments | `SLIPOK_API_URL`, `SLIPOK_API_KEY` |
| Donation payments | `TMN_DEST_PHONE_NUM`, `SASTIFY_API_PRIVKEY` |
| TTS | `GEMINI_TTS_API_KEY` |
| Widget | `DONATE_WIDGET_KEY` |
| Discord | `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_*`, `DISCORD_GUILD_ID`, `DISCORD_WEBHOOK_URL` |
| YouTube | `YOUTUBE_CHANNEL_ID`, `YOUTUBE_API_KEY` |
| Analytics | `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` |
| Category | Variables |
| ----------------- | ---------------------------------------------------------------------------------- |
| Database | `DATABASE_URL`, `REDIS_URL` |
| Auth | `BETTER_AUTH_SECRET` |
| Rubgram payments | `SLIPOK_API_URL`, `SLIPOK_API_KEY` |
| Donation payments | `TMN_DEST_PHONE_NUM`, `SASTIFY_API_PRIVKEY` |
| TTS | `GEMINI_TTS_API_KEY` |
| Widget | `DONATE_WIDGET_KEY` |
| Discord | `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_*`, `DISCORD_GUILD_ID`, `DISCORD_WEBHOOK_URL` |
| YouTube | `YOUTUBE_CHANNEL_ID`, `YOUTUBE_API_KEY` |
| Analytics | `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` |
## Developers
+2
View File
@@ -239,3 +239,5 @@ export default async function CharacterEditPage({
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -213,3 +213,5 @@ export default async function CharacterCreatePage() {
</ModalBase>
);
}
export const dynamic = "force-dynamic";
+2
View File
@@ -1,3 +1,5 @@
export default function () {
return null;
}
export const dynamic = "force-dynamic";
@@ -127,3 +127,5 @@ export default async function GuideEditPage({
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -110,3 +110,5 @@ export default async function GuideCreatePage() {
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -191,3 +191,5 @@ export default async function TlVersionEditPage({
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -189,3 +189,5 @@ export default async function TlVersionCreatePage({
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -133,3 +133,5 @@ export default async function TlTypeEditPage({
</ModalBase>
);
}
export const dynamic = "force-dynamic";
@@ -103,3 +103,5 @@ export default async function TlTypeCreatePage() {
</ModalBase>
);
}
export const dynamic = "force-dynamic";
+5
View File
@@ -0,0 +1,5 @@
export default function () {
return null;
}
export const dynamic = "force-dynamic";
@@ -0,0 +1,89 @@
import { PlusIcon } from "lucide-react";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import z from "zod";
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 { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { settings } from "@/lib/db/schema";
import { formParse } from "@/components/form-submit";
import { sse } from "@/lib/db/sse-endpoints";
import { ActionButton } from "@/components/action-button";
import { resetGoal } from "../../api";
const Schema = z.object({
goal: z.coerce.number().nonnegative().nullable().optional().default(null),
});
export default async function RgManualCreateModal() {
if (!(await adminCheck())) redirect("/login");
const [{ donateGoal }] = await db
.select({ donateGoal: settings.donateGoal })
.from(settings);
async function submit(form: FormData) {
"use server";
if (!(await adminCheck())) redirect("/login");
const { $, error } = formParse(Schema, form);
if (error) return { error };
await db
.insert(settings)
.values({ donateGoal: $.goal })
.onConflictDoUpdate({
target: settings.id,
set: { donateGoal: $.goal },
});
await actionLog(`Donate goal set to ${$.goal}`);
revalidatePath("/donate/admin");
sse.donate.pub("update", null);
return { toast: `Donate goal set to ${$.goal}`, close: true };
}
return (
<ModalBase title="Set Goal">
<FormProvider id="goal-set" onSubmit={submit}>
<FormInput name="goal" label="Price">
<Input
placeholder="0"
type="number"
defaultValue={donateGoal ?? ""}
/>
</FormInput>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<ActionButton action={resetGoal} type="button" variant="destructive">
Reset Goal
</ActionButton>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Setting...
</>
}
>
<PlusIcon />
Set
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
export const dynamic = "force-dynamic";
+20 -1
View File
@@ -2,12 +2,31 @@
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { donations } from "@/lib/db/schema";
import { donations, settings } from "@/lib/db/schema";
import { sse } from "@/lib/db/sse-endpoints";
import { fileToDataUrl } from "@/lib/utils";
import { eq } from "drizzle-orm";
import { getPostHogClient } from "@/lib/posthog-server";
export async function resetGoal() {
if (!(await adminCheck())) throw new Error("Unauthorized");
getPostHogClient().capture({
distinctId: "admin",
event: "donation_admin_goal_reset",
});
await db
.insert(settings)
.values({ donateGoalStarting: new Date() })
.onConflictDoUpdate({
target: settings.id,
set: { donateGoalStarting: new Date() },
});
sse.donate.pub("update", null);
}
export async function testPopup() {
if (!(await adminCheck())) throw new Error("Unauthorized");
+10
View File
@@ -14,6 +14,7 @@ import {
BugPlay,
ChevronsLeftRightEllipsis,
Copy,
Goal,
ImageIcon,
MessageCircleWarning,
} from "lucide-react";
@@ -24,6 +25,8 @@ import { useEffect } from "react";
import posthog from "posthog-js";
import { sse } from "@/lib/db/sse-endpoints";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import Link from "next/link";
const columns: ColumnDef<typeof donations.$inferSelect>[] = [
{ accessorKey: "name", header: "ชื่อ", meta: { className: "w-50 truncate" } },
@@ -89,6 +92,13 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
<ChevronsLeftRightEllipsis />
</ActionButton>
</SimpleTooltip>
<SimpleTooltip text="Set Goal">
<Button asChild variant="outline" size="icon-sm">
<Link href="/donate/admin/goal">
<Goal />
</Link>
</Button>
</SimpleTooltip>
</div>
);
},
+18
View File
@@ -0,0 +1,18 @@
import { ErrorModal } from "@/components/error";
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
import type { ReactNode } from "react";
export default async function DonateAdminLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
return (
<>
<ErrorBoundary errorComponent={ErrorModal}>{modal}</ErrorBoundary>
{children}
</>
);
}
@@ -1,38 +0,0 @@
"use client";
import { Database, ReceiptText } from "lucide-react";
import { useMemo, useState } from "react";
import { CopyButton } from "@/app/(ui)/artifact/admin/[id]/client";
import { Button } from "@/components/ui/button";
export function DataViewer({
entryYaml,
slipYaml,
}: {
entryYaml: string;
slipYaml: string;
}) {
const [isSlip, setIsSlip] = useState(false);
const yaml = useMemo(
() => (isSlip ? slipYaml : entryYaml),
[entryYaml, isSlip, slipYaml],
);
return (
<div className="relative max-h-[60svh] overflow-auto rounded-xl border bg-black/40 p-4 font-mono text-xs leading-relaxed">
<div className="sticky -top-2 -mt-10 -mr-2 flex justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => setIsSlip((x) => !x)}
>
{isSlip ? <ReceiptText /> : <Database />}
</Button>
<CopyButton text={yaml} />
</div>
<pre className="wrap-break-word whitespace-pre-wrap">{yaml}</pre>
</div>
);
}
@@ -1,102 +0,0 @@
import { calcPrice } from "@/app/(ui)/rubgram/api";
import Image from "@/components/image";
import { DiscordMentionable } from "@/components/mentionable";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { db } from "@/lib/db";
import { endgameArchive, endgameDiscord, endgameSlips } from "@/lib/db/schema";
import { parseSearchNumber } from "@/lib/utils";
import { YAML } from "bun";
import { and, eq, getTableColumns } from "drizzle-orm";
import { ReceiptText } from "lucide-react";
import { DataViewer } from "./client";
export default async function ({
params,
}: PageProps<"/rubgram/admin/slip/[round]/[queue]">) {
const { round: r, queue: q } = await params;
const round = parseSearchNumber(r) || 1;
const queue = parseSearchNumber(q) || 1;
const { slip: _, ...slipColumns } = getTableColumns(endgameSlips);
const [entry] = await db
.select({
...getTableColumns(endgameArchive),
user: getTableColumns(endgameDiscord),
slip: slipColumns,
})
.from(endgameArchive)
.where(
and(eq(endgameArchive.round, round), eq(endgameArchive.queue, queue)),
)
.innerJoin(endgameDiscord, eq(endgameDiscord.uid, endgameArchive.user))
.leftJoin(endgameSlips, eq(endgameSlips.id, endgameArchive.slip))
.limit(1);
const entryYaml = YAML.stringify(
{ ...entry, slip: { ...entry.slip, data: undefined } },
null,
2,
);
const slipYaml = YAML.stringify(entry.slip, null, 2);
if (!entry)
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ReceiptText />
</EmptyMedia>
<EmptyTitle>ยังไม่มีสลิป</EmptyTitle>
</EmptyHeader>
</Empty>
);
return (
<div className="flex h-full flex-col p-2 sm:flex-row">
<div className="flex flex-1 items-center justify-center p-5">
<div className="relative rounded border border-dashed border-white">
<Image
src={`/api/slip/${entry.slip?.id}`}
alt="Slip"
className="object-contain"
width={1000}
height={1000}
/>
<div className="absolute top-0 right-0 bottom-0 left-0 backdrop-blur-lg transition-opacity hover:opacity-0" />
</div>
</div>
<div className="flex flex-1 items-center justify-center">
<Card>
<CardHeader>
<CardTitle>จ่าย {entry.price} บาท</CardTitle>
<CardDescription>
{entry.service.join(", ")} (รวม {calcPrice(entry.service)} บาท)
</CardDescription>
<CardAction>
<DiscordMentionable
id={entry.user.uid}
name={entry.user.display}
type="user"
/>
</CardAction>
</CardHeader>
<CardContent>
<DataViewer {...{ entryYaml, slipYaml }} />
</CardContent>
</Card>
</div>
</div>
);
}
@@ -1,201 +0,0 @@
import { eq, sql } from "drizzle-orm";
import { ChevronLeft, ChevronRight, ReceiptText } from "lucide-react";
import Link from "next/link";
import { cache, Suspense } from "react";
import { SidebarLink } from "@/app/(ui)/admin/client";
import { SimpleTooltip } from "@/components/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { db } from "@/lib/db";
import { endgameArchive } from "@/lib/db/schema";
import { parseSearchNumber } from "@/lib/utils";
import { Watcher } from "../../../../admin/client";
export default async function ({
params,
children,
}: {
params: Promise<{ round: string }>;
children: React.ReactNode;
}) {
const { round: r } = await params;
const round = parseSearchNumber(r);
return (
<SidebarProvider>
<Suspense fallback={<Sidebar />}>
<ThisSidebar round={round} />
</Suspense>
<SidebarInset className="bg-transparent">
{children}
<div className="absolute bottom-1 left-1 block opacity-50 hover:opacity-100 md:hidden">
<SidebarTrigger />
</div>
<Watcher />
</SidebarInset>
</SidebarProvider>
);
}
const getRange = cache(
async () =>
await db
.select({
min: sql<number>`min(${endgameArchive.round})`,
max: sql<number>`max(${endgameArchive.round})`,
})
.from(endgameArchive),
);
const getEntries = cache(
async (round: number) =>
await db
.select()
.from(endgameArchive)
.where(eq(endgameArchive.round, round))
.orderBy(endgameArchive.queue),
);
async function ThisSidebar({ round }: { round: number }) {
const [[{ min, max }], entries] = await Promise.all([
getRange(),
getEntries(round),
]);
return (
<Sidebar variant="floating">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
asChild
>
<span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-foreground">
<ReceiptText className="size-4" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width)"
align="start"
>
<DropdownMenuGroup>
<DropdownMenuLabel>เลือกรอบ</DropdownMenuLabel>
<DropdownMenuRadioGroup value={`${round}`}>
{range(min, max + 1)
.toReversed()
.map((r) => (
<Link href={`/rubgram/admin/slip/${r}`} key={r}>
<DropdownMenuRadioItem value={`${r}`}>
รอบที่ {r}
</DropdownMenuRadioItem>
</Link>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-medium">ประวัติสลิปรายได้</span>
<span>รอบที่ {round}</span>
</div>
<div className="ml-auto flex h-full items-center justify-evenly">
{round <= min ? (
<ChevronLeft className="size-4 text-muted-foreground" />
) : (
<SimpleTooltip text="รอบก่อนหน้า" side="right">
<Link
href={`/rubgram/admin/slip/${round - 1}`}
className="flex h-full items-center"
>
<ChevronLeft className="size-4" />
</Link>
</SimpleTooltip>
)}
{round >= max ? (
<ChevronRight className="size-4 text-muted-foreground" />
) : (
<SimpleTooltip text="รอบถัดไป" side="right">
<Link
href={`/rubgram/admin/slip/${round + 1}`}
className="flex h-full items-center"
>
<ChevronRight className="size-4" />
</Link>
</SimpleTooltip>
)}
</div>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
{entries.map((e) => (
<SidebarMenu key={e.id}>
<SidebarMenuItem>
<SidebarLink
prefetch
href={`/rubgram/admin/slip/${round}/${e.queue}`}
>
{e.queue}. {e.name}
</SidebarLink>
</SidebarMenuItem>
</SidebarMenu>
))}
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="justify-center">
รายได้รวม {entries.reduce((a, e) => a + e.price, 0)} บาท
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
const range = (start: number, stop?: number, step?: number) => {
// Handle cases where stop/step are optional (e.g., range(5) -> [0, 1, 2, 3, 4])
if (stop === undefined) {
[stop, start] = [start, 0];
}
if (step === undefined) {
step = start < stop ? 1 : -1;
}
// Calculate the length of the array
const length = Math.ceil((stop - start) / step);
// Create the array using Array.from() and a map function
return Array.from({ length }, (_, index) => start + index * step);
};
@@ -1,39 +0,0 @@
import { eq } from "drizzle-orm";
import { ReceiptText } from "lucide-react";
import { redirect } from "next/navigation";
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { db } from "@/lib/db";
import { endgameArchive } from "@/lib/db/schema";
import { parseSearchNumber } from "@/lib/utils";
export default async function ({
params,
}: {
params: Promise<{ round: string }>;
}) {
const { round: r } = await params;
const round = parseSearchNumber(r) || 1;
const [first] = await db
.select({ queue: endgameArchive.queue })
.from(endgameArchive)
.where(eq(endgameArchive.round, round))
.orderBy(endgameArchive.queue)
.limit(1);
if (first) redirect(`/rubgram/admin/slip/${round}/${first.queue}`);
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ReceiptText />
</EmptyMedia>
<EmptyTitle>ยังไม่มีสลิป</EmptyTitle>
</EmptyHeader>
</Empty>
);
}
@@ -1,13 +0,0 @@
import { sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { endgameArchive } from "@/lib/db/schema";
export async function GET() {
const [{ max }] = await db
.select({
max: sql<number>`max(${endgameArchive.round})`,
})
.from(endgameArchive);
redirect(`/rubgram/admin/slip/${max || "1"}`);
}
@@ -1,3 +1,5 @@
export default function () {
return null;
}
export const dynamic = "force-dynamic";
@@ -160,3 +160,5 @@ export default async function RgManualCreateModal() {
</ModalBase>
);
}
export const dynamic = "force-dynamic";
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { format, getDaysInMonth, startOfMonth } from "date-fns";
import {
Check,
ChevronLeft,
ChevronRight,
Circle,
ExternalLink,
ImageOff,
ReceiptText,
Trash2,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useTransition } from "react";
import { toast } from "sonner";
import { SimpleTooltip } from "@/components/tooltip";
import { toggleMonth } from "../../api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type Row = {
id: string;
name: string;
queue: number;
checked: boolean;
server: "as" | "eu" | "us" | "tw";
expires: Date | null;
price: number;
service: string[];
slip: {
id: string;
ref: string;
amount: string;
data: unknown;
} | null;
paid: boolean;
deleted: boolean;
submit_day: Date;
user: {
uid: string;
display: string;
username: string;
token: string;
} | null;
};
const DAYS = ["จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส.", "อา."];
export function CalendarClient({
year,
month,
rows,
total,
monthly,
}: {
year: number;
month: number;
rows: Row[];
total: number;
monthly: Record<string, boolean>;
}) {
const current = new Date(year, month);
const rowsByDay = useMemo(() => {
const map: Record<number, Row[]> = {};
for (const r of rows) {
if (!r.submit_day) continue;
const d = new Date(r.submit_day).getDate();
(map[d] ??= []).push(r);
}
return map;
}, [rows]);
const ym = `${year}-${String(month + 1).padStart(2, "0")}`;
const [pending, startTransition] = useTransition();
const monthChecked = monthly[ym] ?? false;
const daysInMonth = getDaysInMonth(current);
const firstDayOfWeek = startOfMonth(current).getDay(); // 0=Sun
const monthLabel = format(current, "MMMM yyyy");
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 = `/rubgram/admin/calendar?month=${prevY}-${String(prevM + 1).padStart(2, "0")}`;
const nextHref = `/rubgram/admin/calendar?month=${nextY}-${String(nextM + 1).padStart(2, "0")}`;
return (
<div className="flex h-full flex-col p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href={prevHref} prefetch>
<ChevronLeft />
</Link>
</Button>
<span className="inline-block min-w-35 shrink-0 text-center text-lg font-semibold">
{monthLabel}
</span>
<Button variant="outline" size="icon" asChild>
<Link href={nextHref} prefetch>
<ChevronRight />
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<SimpleTooltip
text={monthChecked ? "ทำเรื่องเงินแล้ว" : "ยังไม่ได้ทำเรื่องเงิน"}
>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(() => {
toggleMonth(ym).catch((e) => toast.error(e as string));
})
}
className="cursor-pointer"
>
{monthChecked ? (
<Check className="size-5 text-green-500" />
) : (
<Circle className="size-5 text-muted-foreground" />
)}
</button>
</SimpleTooltip>
<div className="text-muted-foreground">
รวม{" "}
<span className="font-semibold text-foreground">
{total.toLocaleString()}
</span>{" "}
บาท
</div>
</div>
</div>
<div className="grid flex-1 grid-cols-7 grid-rows-[min-content_repeat(6,minmax(0,1fr))] gap-px rounded-lg bg-black/25">
{DAYS.map((d) => (
<div
key={d}
className="p-2 text-center text-xs font-medium text-muted-foreground"
>
{d}
</div>
))}
{/* some hack for month start */}
{Array.from({
length: firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1,
}).map((_, i) => (
<div key={`pad-${i}`} />
))}
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map((day) => {
const dayRows = rowsByDay[day] || [];
const dayTotal = dayRows.reduce((s, r) => s + r.price, 0);
const hasData = dayRows.length > 0;
return (
<Dialog key={day}>
<DialogTrigger asChild>
<button
type="button"
className={cn(
"flex flex-col items-start gap-0.5 rounded-md border p-2 text-left transition-colors",
hasData
? "cursor-pointer border-primary/30 bg-primary/5 hover:bg-primary/10"
: "border-transparent",
!hasData && "cursor-default",
)}
>
<span className="text-sm">{day}</span>
{hasData && (
<>
<Badge
variant="secondary"
className="h-4 px-1 py-0 text-[10px]"
>
{dayRows.length} คิว
</Badge>
<span className="text-[10px] text-muted-foreground">
{dayTotal.toLocaleString()} ฿
</span>
</>
)}
</button>
</DialogTrigger>
<DialogContent className="max-h-[80svh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>
{format(
new Date(current.getFullYear(), current.getMonth(), day),
"d MMM yyyy",
)}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
{dayRows.length === 0 && (
<p className="text-sm text-muted-foreground">ไม่มีคิวในวันนี้</p>
)}
{dayRows.map((r) => (
<div
key={r.id}
className={cn(
"flex items-start gap-3 rounded-md border p-3",
r.deleted && "opacity-50",
)}
>
{r.slip ? (
<div className="relative size-16 shrink-0 overflow-hidden rounded">
<Image
src={`/api/slip/${r.slip.id}`}
alt="Slip"
fill
className="object-cover blur-sm brightness-50"
/>
</div>
) : r.price <= 0 ? (
<div className="flex size-16 shrink-0 items-center justify-center rounded bg-muted">
<ReceiptText className="size-6 text-muted-foreground" />
</div>
) : (
<div className="flex size-16 shrink-0 items-center justify-center rounded bg-muted">
<ImageOff className="size-6 text-muted-foreground" />
</div>
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{r.queue}. {r.name}
</span>
{r.deleted && (
<Tooltip>
<TooltipTrigger>
<Trash2 className="size-3.5 shrink-0 text-destructive" />
</TooltipTrigger>
<TooltipContent>ลบแล้ว</TooltipContent>
</Tooltip>
)}
</div>
<span className="truncate text-xs text-muted-foreground">
{r.service.join(", ")}
</span>
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{r.price} ฿</span>
{r.user && (
<span className="text-muted-foreground">
{r.user.display}
</span>
)}
{r.slip && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-auto size-5"
>
<ExternalLink className="size-3" />
</Button>
</DialogTrigger>
<DialogContent className="flex h-full max-w-dvw! flex-col bg-[#2225] backdrop-blur-xs">
<DialogTitle className="h-min">สลิป</DialogTitle>
<DialogClose asChild>
<div className="relative flex h-full w-full grow justify-center">
<Image
src={`/api/slip/${r.slip.id}`}
alt="Slip"
fill
className="object-contain"
/>
</div>
</DialogClose>
</DialogContent>
</Dialog>
)}
</div>
</div>
</div>
))}
</div>
<DialogFooter>
<span className="text-sm text-muted-foreground">
รวม {dayTotal.toLocaleString()} บาท
</span>
</DialogFooter>
</DialogContent>
</Dialog>
);
})}
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { and, between, eq, getTableColumns } from "drizzle-orm";
import { startOfMonth, endOfMonth } from "date-fns";
import { redirect } from "next/navigation";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import {
endgameDiscord,
endgameSettings,
endgameSlips,
endgameSubmissions,
} from "@/lib/db/schema";
import { CalendarClient } from "./client";
export const metadata = { title: "ปฏิทินคิว" };
export default async function CalendarPage({
searchParams,
}: {
searchParams: Promise<{ month?: string }>;
}) {
if (!(await adminCheck()))
redirect(`/login?next=${encodeURIComponent("/rubgram/admin/calendar")}`);
const { month } = await searchParams;
const date = month ? new Date(month + "-01T00:00:00") : new Date();
if (isNaN(date.getTime())) redirect("/rubgram/admin/calendar");
const from = startOfMonth(date);
const to = endOfMonth(date);
const { slip: _, ...slipColumns } = getTableColumns(endgameSlips);
const [rows, [settings]] = await Promise.all([
db
.select({
...getTableColumns(endgameSubmissions),
user: getTableColumns(endgameDiscord),
slip: slipColumns,
})
.from(endgameSubmissions)
.where(and(between(endgameSubmissions.submit_day, from, to)))
.leftJoin(endgameDiscord, eq(endgameDiscord.uid, endgameSubmissions.user))
.leftJoin(endgameSlips, eq(endgameSlips.id, endgameSubmissions.slip))
.orderBy(endgameSubmissions.submit_day, endgameSubmissions.queue),
db
.select({ monthly: endgameSettings.monthly })
.from(endgameSettings)
.limit(1),
]);
const total = rows.reduce((s, r) => s + r.price, 0);
const monthly = settings?.monthly ?? {};
return (
<CalendarClient
year={date.getFullYear()}
month={date.getMonth()}
rows={rows}
total={total}
monthly={monthly}
/>
);
}
+15 -20
View File
@@ -1,7 +1,7 @@
"use client";
import { useProgress } from "@bprogress/next";
import { Lock, Logs, Unlock } from "lucide-react";
import { Calendar, Lock, Unlock } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@@ -72,7 +72,8 @@ export function SidebarLink({
}
}}
>
{submission.queue}. {submission.name}
{submission.checked ? <div className="px-1" /> : `${submission.queue}. `}
{submission.name}
<SidebarMenuBadge className="pointer-events-auto">
<Checkbox
className="mr-2"
@@ -251,10 +252,9 @@ export function SubmissionList({
id: string;
name: string;
checked: boolean;
queue: number;
publicQueue: number;
paid: boolean;
archived: boolean;
deleted: boolean;
}[];
}) {
const [query, setQuery] = useState("");
@@ -277,18 +277,18 @@ export function SubmissionList({
{subs
.filter(
(s) =>
(debug || (s.paid && !s.archived)) &&
(s.queue + s.name).toLowerCase().includes(query),
(debug || (s.paid && !s.deleted)) &&
(s.publicQueue + s.name).toLowerCase().includes(query),
)
.map((s) => (
<SidebarMenuButton
key={s.id}
asChild
className={cn(((debug && !s.paid) || s.archived) && "opacity-50")}
className={cn(((debug && !s.paid) || s.deleted) && "opacity-50")}
>
<SidebarLink
submission={debug ? s : { ...s, queue: s.publicQueue }}
prefetch={!s.archived}
submission={{ ...s, queue: s.publicQueue }}
prefetch={!s.deleted}
/>
</SidebarMenuButton>
))}
@@ -324,23 +324,18 @@ export function Watcher() {
return <div></div>;
}
export function SlipButton() {
export function CalendarButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => {
window.location.href = "/rubgram/admin/slip";
}}
>
<Logs size={24} className="size-6" />
<Button variant="ghost" size="icon" className="size-8" asChild>
<Link href="/rubgram/admin/calendar">
<Calendar size={24} className="size-6" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>All time คิว</p>
<p>ปฏิทินคิว</p>
</TooltipContent>
</Tooltip>
);
+13 -10
View File
@@ -28,8 +28,13 @@ import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { endgameSubmissions } from "@/lib/db/schema";
import { getEndgameConfig, random, wipe } from "../api";
import { LimitManager, SlipButton, SubmissionList, Watcher } from "./client";
import { desc, sql } from "drizzle-orm";
import {
CalendarButton,
LimitManager,
SubmissionList,
Watcher,
} from "./client";
import { sql } from "drizzle-orm";
import Link from "next/link";
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
import { ErrorModal } from "@/components/error";
@@ -48,21 +53,19 @@ export default async function AdminLayout({
id: endgameSubmissions.id,
name: endgameSubmissions.name,
checked: endgameSubmissions.checked,
queue: endgameSubmissions.queue,
publicQueue: sql<number>`
${endgameSubmissions.queue} - (
select count(*)
select count(*)::int
from ${endgameSubmissions} e2
where e2.checked = true
and e2.queue < ${endgameSubmissions.queue}
or e2.archived
where (e2.checked and e2.queue < endgame.submissions.queue)
or e2.deleted
)
`,
paid: endgameSubmissions.paid,
archived: endgameSubmissions.archived,
deleted: endgameSubmissions.deleted,
})
.from(endgameSubmissions)
.orderBy(desc(endgameSubmissions.archived), endgameSubmissions.id);
.orderBy(endgameSubmissions.deleted, endgameSubmissions.id);
const config = await getEndgameConfig();
return (
<SidebarProvider>
@@ -84,7 +87,7 @@ export default async function AdminLayout({
>
<Dice5 size={24} className="size-6" />
</Button>
<SlipButton />
<CalendarButton />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
+26 -44
View File
@@ -8,7 +8,6 @@ import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import {
endgameArchive,
endgameDiscord,
endgameExpired,
endgameSettings,
@@ -23,52 +22,15 @@ import { checkSlip } from "@/lib/payment";
const { DISCORD_WEBHOOK_URL, DISCORD_CLIENT_ID, BASE_URL } =
process.env as Record<string, string>;
export async function archive({ onlyChecked = true, copy = true }) {
if (!(await adminCheck())) throw "Unauthorized";
await db.transaction(async (tx) => {
const deleted = await (
copy
? tx
.update(endgameSubmissions)
.set({
archived: true,
})
.returning()
: tx.delete(endgameSubmissions).returning()
).where(
and(
onlyChecked ? eq(endgameSubmissions.checked, true) : undefined,
not(endgameSubmissions.archived),
),
);
if (!onlyChecked && !copy)
await tx.execute(
sql`ALTER SEQUENCE endgame.submissions_queue_seq RESTART WITH 1`,
);
const [{ maxRound }] = await tx
.select({ maxRound: sql<number>`MAX(${endgameArchive.round})` })
.from(endgameArchive);
await tx.insert(endgameArchive).values(
deleted
.filter((e) => e.paid)
.map((s) => ({
...s,
round: maxRound + 1,
})),
);
});
}
export async function wipe() {
if (!(await adminCheck())) throw "Unauthorized";
await archive({ onlyChecked: false });
await db.update(endgameSubmissions).set({ deleted: true });
revalidatePath("/rubgram/admin");
revalidatePath("/rubgram");
sse.rubgram.pub("update", { type: "wipe" });
await actionLog(`Deleted rubgram submissions`);
redirect("/rubgram/admin");
}
export async function random() {
@@ -165,6 +127,23 @@ export async function setFree(free: number) {
await actionLog(`Set rubgram free submission amount to ${free}`);
}
export async function toggleMonth(month: string) {
if (!(await adminCheck())) throw "Unauthorized";
const [s] = await db
.select({ monthly: endgameSettings.monthly })
.from(endgameSettings)
.limit(1);
const monthly = {
...(s?.monthly ?? {}),
[month]: !(s?.monthly?.[month] ?? false),
};
await db
.update(endgameSettings)
.set({ monthly })
.where(eq(endgameSettings.id, true));
revalidatePath("/rubgram/admin/calendar");
}
export async function getEndgameConfig() {
const [ngm]: (typeof endgameSettings.$inferSelect | undefined)[] = await db
.select()
@@ -222,7 +201,7 @@ export async function submitEndgame(formData: FormData) {
.where(
and(
eq(endgameSubmissions.user, user),
not(endgameSubmissions.archived),
not(endgameSubmissions.deleted),
not(endgameSubmissions.checked),
not(endgameSubmissions.paid),
),
@@ -265,7 +244,9 @@ export async function submitEndgamePayment(formData: FormData) {
const [s] = await tx
.select()
.from(endgameSubmissions)
.where(eq(endgameSubmissions.id, sid))
.where(
and(eq(endgameSubmissions.id, sid), not(endgameSubmissions.deleted)),
)
.limit(1);
const [a] = await tx
.select()
@@ -410,9 +391,10 @@ export async function removeExpiredSubmissions() {
}
export async function cancel(sid: string) {
await db.delete(endgameSubmissions).where(eq(endgameSubmissions.id, sid));
await removeExpiredSubmissions();
await db
.update(endgameSubmissions)
.set({ deleted: true })
.where(eq(endgameSubmissions.id, sid));
sse.rubgram.pub("update", { type: "cancel" });
+2 -2
View File
@@ -87,7 +87,7 @@ export default async function EndgamePage() {
select count(*)
from ${endgameSubmissions} e2
where e2.checked = true
and e2.queue < ${endgameSubmissions.queue}
and e2.queue < endgame.submissions.queue
)
`,
paid: endgameSubmissions.paid,
@@ -104,7 +104,7 @@ export default async function EndgamePage() {
gt(endgameSubmissions.expires, new Date()),
),
),
not(endgameSubmissions.archived),
not(endgameSubmissions.deleted),
),
)
: [];
+4 -1
View File
@@ -1,6 +1,9 @@
import { not } from "drizzle-orm";
import { db } from "@/lib/db";
import { endgameSubmissions } from "@/lib/db/schema";
export async function GET() {
return Response.json(await db.$count(endgameSubmissions));
return Response.json(
await db.$count(endgameSubmissions, not(endgameSubmissions.deleted)),
);
}
+21
View File
@@ -0,0 +1,21 @@
"use server";
import { getArtifactConfig } from "@/lib/api";
import { db } from "@/lib/db";
import { donations, settings } from "@/lib/db/schema";
import { gt, sum } from "drizzle-orm";
export async function getDonateBar() {
const { donateGoalStarting } = await getArtifactConfig();
const [{ amount }] = await db
.select({
amount: sum(donations.amount),
})
.from(donations)
.where(gt(donations.created, donateGoalStarting ?? new Date(0)));
const [{ goal }] = await db
.select({ goal: settings.donateGoal })
.from(settings)
.limit(1);
return { amount, goal };
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { sse } from "@/lib/db/sse-endpoints";
import { useEffect, useRef, useState } from "react";
import { getDonateBar } from "./api";
import { motion } from "motion/react";
import { pausePass } from "@/lib/utils";
type Data = Awaited<ReturnType<typeof getDonateBar>>;
export default function TopDonateWidget() {
const [bar, setBar] = useState<Data>();
const textRef = useRef<HTMLDivElement | null>(null);
const stableSize = useRef<Record<string, number>>({});
const [resizeTick, setResizeTick] = useState(0);
useEffect(() => {
async function update() {
if (textRef.current)
void getDonateBar().then(pausePass(10000)).then(setBar);
else void getDonateBar().then(setBar);
}
queueMicrotask(update);
const { clean } = sse.donate.subMany({
update,
ping: update,
refresh: () => location.reload(),
});
const backupInterval = setInterval(update, 300000);
return () => {
clean();
clearInterval(backupInterval);
};
}, []);
// the most idiomatic way possible but it works
useEffect(() => {
// oxlint-disable-next-line no-unused-expressions
resizeTick; // trigger re-run on window resize
if (!textRef.current) return;
var fontSize = 48,
count = 0;
const el = textRef.current,
parent = el.parentElement!,
key = `${bar?.goal}`;
if (stableSize.current[key]) {
el.classList.add("opacity-100");
el.style.fontSize = `${stableSize.current[key]}px`;
return;
}
el.classList.remove("opacity-100");
const interval = setInterval(() => {
console.log(el.scrollHeight, parent.clientHeight);
fontSize = (fontSize * parent.clientHeight) / el.scrollHeight;
el.style.fontSize = `${fontSize}px`;
count++;
if (count > 10) {
stableSize.current[key] = fontSize;
el.classList.add("opacity-100");
clearInterval(interval);
}
}, 100);
return () => {
el.classList.add("opacity-100");
clearInterval(interval);
};
}, [bar, resizeTick]);
useEffect(() => {
const onResize = () => {
stableSize.current = {};
setResizeTick((t) => t + 1);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [bar]);
return bar ? (
<div className="relative h-full max-h-[20svw] w-full rounded-full bg-[#252525]">
<div className="absolute inset-2 rounded-full bg-black" />
<motion.div
className="absolute inset-1.5 rounded-full bg-linear-to-b from-[#FD0000] to-[#830000]"
animate={{
width: `calc(${Math.min(1, Number(bar.amount ?? "0") / (bar.goal ?? 1)) * 100}% - ${Math.min(1, Number(bar.amount ?? "0") / (bar.goal ?? 1)) * 12}px)`,
// width: `calc(6px + ${Math.min(1, Number(bar.amount) / bar.goal)} * (100% - 12px))`,
}}
initial={{
width: 0,
}}
/>
<div
ref={textRef}
className="absolute top-1/2 right-8 -translate-y-1/2 pb-1 font-bold opacity-0 transition-opacity"
>
{bar.goal}฿
</div>
</div>
) : (
""
);
}
+9 -2
View File
@@ -16,11 +16,18 @@ export default function TopDonateWidget() {
void getTopDonate().then(setTop);
}
queueMicrotask(update);
return sse.donate.subMany({
const { clean } = sse.donate.subMany({
update,
ping: update,
refresh: () => location.reload(),
}).clean;
});
const backupInterval = setInterval(update, 300000);
return () => {
clean();
clearInterval(backupInterval);
};
}, []);
// the most idiomatic way possible but it works
+1 -8
View File
@@ -2,7 +2,7 @@ import { env } from "node:process";
import { cron, redis } from "bun";
import { formatDistanceToNow } from "date-fns";
import { and, asc, eq, gt, isNull, lt, not, sql } from "drizzle-orm";
import { archive, removeExpiredSubmissions } from "@/app/(ui)/rubgram/api";
import { removeExpiredSubmissions } from "@/app/(ui)/rubgram/api";
import { auth, issueInternalToken } from "@/lib/auth";
import { db } from "@/lib/db";
import {
@@ -272,13 +272,6 @@ async function cacheCards() {
} else schedule(120, cacheCards);
}
cron("0 0 1 * *", async function rubgramArchive() {
const { log } = logger("rubgramArchive");
log("Started...");
await archive({});
log("Done");
});
cron("0 0 */14 * *", async function syncAmber() {
const { log, error } = logger("syncAmber");
log("Syncing...");
+5 -15
View File
@@ -75,6 +75,8 @@ 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
donateGoal: numeric({ mode: "number" }),
donateGoalStarting: timestamp(),
});
//#endregion
@@ -157,21 +159,8 @@ export const endgameSubmissions = endgame.table("submissions", {
isNotNull(endgameSubmissions.slip),
)!,
),
archived: boolean().notNull().default(false),
});
export const endgameArchive = endgame.table("sarchive", {
id: text().primaryKey(),
name: text().notNull(),
user: text().notNull(),
queue: integer().notNull(),
price: integer().notNull(),
slip: text().references(() => endgameSlips.id),
round: integer().notNull(),
service: text()
.references(() => endgameTypes.id)
.array()
.notNull(),
deleted: boolean().notNull().default(false),
submit_day: timestamp().notNull().defaultNow(),
});
// expired submission
@@ -205,6 +194,7 @@ export const endgameSettings = endgame.table("settings", {
limit: integer().notNull().default(-1),
free: integer().notNull().default(0),
allDiscount: integer().notNull().default(10),
monthly: jsonb().$type<Record<string, boolean>>().notNull().default({}),
});
export const endgameDiscord = endgame.table("discord", {
+4
View File
@@ -68,3 +68,7 @@ export function parseSearchString(
if (Array.isArray(param)) return parseSearchString(param[0], def);
return param ?? def;
}
export function pausePass<R>(time: number): (res: R) => Promise<R> {
return (res) => new Promise((r) => setTimeout(() => r(res), time));
}