Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
""
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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", {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user