feat: add admin slip management pages and update navbar styles

This commit is contained in:
2026-02-05 08:22:51 +07:00 Unverified
parent 3b74689836
commit 18fd204b6e
11 changed files with 396 additions and 11 deletions
+8 -3
View File
@@ -44,7 +44,7 @@ function Item({
return ( return (
<Link <Link
className={cn( className={cn(
"group transition-[width] flex gap-2", "group transition-[width] flex gap-1",
divide && "border-r-2 -mr-1 pr-1", divide && "border-r-2 -mr-1 pr-1",
)} )}
href={href} href={href}
@@ -52,8 +52,13 @@ function Item({
{icon}{" "} {icon}{" "}
<span <span
className={cn( className={cn(
"hidden group-hover:block text-xs", "overflow-hidden whitespace-nowrap text-xs",
divide || last || "border-r-2 -mr-1 pr-1", "max-w-0 opacity-0",
"group-hover:max-w-40 group-hover:opacity-100",
"transition-all duration-300 ease-out delay-200",
"-mr-1",
divide || last || "border-r-2 -mr-3 pr-1",
divide && "group-hover:mr-0",
)} )}
> >
{name} {name}
@@ -0,0 +1,102 @@
import { YAML } from "bun";
import { and, eq, getTableColumns } from "drizzle-orm";
import { ReceiptText } from "lucide-react";
import { CopyButton } from "@/app/(ui)/artifact/admin/[id]/client";
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 { parseParamNumber } from "@/lib/utils";
export default async function ({
params,
}: PageProps<"/rubgram/admin/slip/[round]/[queue]">) {
const { round: r, queue: q } = await params;
const round = parseParamNumber(r) || 1;
const queue = parseParamNumber(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))
.innerJoin(endgameSlips, eq(endgameSlips.id, endgameArchive.slip))
.limit(1);
if (!entry)
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ReceiptText />
</EmptyMedia>
<EmptyTitle>ยังไม่มีสลิป</EmptyTitle>
</EmptyHeader>
</Empty>
);
return (
<div className="flex h-full p-2">
<div className="flex flex-1 justify-center items-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 left-0 bottom-0 right-0 backdrop-blur-lg hover:opacity-0 transition-opacity" />
</div>
</div>
<div className="flex justify-center items-center flex-1">
<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>
<div className="relative max-h-[60svh] overflow-auto rounded-xl border bg-black/40 p-4 text-xs font-mono leading-relaxed">
<div className="sticky -top-2 -mr-2 flex justify-end -mt-10">
<CopyButton text={JSON.stringify(entry, null, 2)} />
</div>
<pre className="whitespace-pre-wrap wrap-break-word">
{YAML.stringify(entry, null, 2)}
</pre>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,199 @@
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 { parseParamNumber } 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 = parseParamNumber(r);
return (
<SidebarProvider>
<Suspense fallback={<Sidebar />}>
<ThisSidebar round={round} />
</Suspense>
<SidebarInset className="bg-transparent">
{children}
<div className="absolute block md:hidden opacity-50 hover:opacity-100 bottom-1 left-1">
<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="bg-sidebar-primary text-sidebar-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<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="flex ml-auto h-full justify-evenly items-center">
{round <= min ? (
<ChevronLeft className="size-4 text-muted-foreground" />
) : (
<SimpleTooltip text="รอบก่อนหน้า" side="right">
<Link
href={`/rubgram/admin/slip/${round - 1}`}
className="h-full flex 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="h-full flex 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">รายได้</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);
};
@@ -0,0 +1,39 @@
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 { parseParamNumber } from "@/lib/utils";
export default async function ({
params,
}: {
params: Promise<{ round: string }>;
}) {
const { round: r } = await params;
const round = parseParamNumber(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>
);
}
@@ -0,0 +1,13 @@
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"}`);
}
+9 -2
View File
@@ -21,7 +21,14 @@ export async function GET(
>, >,
); );
if (!res.playerInfo) return Response.json(res); if (!res.playerInfo) return Response.json(res);
if (res.ttl) if (res.ttl) {
await redis.setex(`enka:${uid}`, res.ttl - 2, JSON.stringify(res)); const ttl = res.ttl - 2;
await redis.setex(
`enka:${uid}`,
// sometimes it goes crazy
Number.isNaN(ttl) || ttl < 0 || ttl > 1000 ? 60 : ttl,
JSON.stringify(res),
);
}
return Response.json(res); return Response.json(res);
} }
+1 -1
View File
@@ -38,7 +38,7 @@ function logger(group: string) {
timeout = target.length timeout = target.length
? target ? target
.map((d) => d.getTime() - now) .map((d) => d.getTime() - now)
.reduce((a, b) => Math.min(a, b), 0) .reduce((a, b) => Math.min(a, b), 3600000)
: 60000; : 60000;
if (typeof target === "number" || target.length) if (typeof target === "number" || target.length)
console.log( console.log(
+6 -2
View File
@@ -6,12 +6,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function SimpleTooltip({ export function SimpleTooltip({
text, text,
children, children,
side,
...props ...props
}: { text?: ReactNode } & React.ComponentProps<typeof Tooltip>) { }: {
text?: ReactNode;
side?: "top" | "right" | "bottom" | "left" | undefined;
} & React.ComponentProps<typeof Tooltip>) {
return ( return (
<Tooltip {...props}> <Tooltip {...props}>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{text}</TooltipContent> <TooltipContent side={side}>{text}</TooltipContent>
</Tooltip> </Tooltip>
); );
} }
+5 -3
View File
@@ -7,9 +7,11 @@ services:
build: build:
context: .. context: ..
dockerfile: dev.dockerfile dockerfile: dev.dockerfile
volumes: develop:
- ../app:/home/container/app watch:
- ../lib:/home/container/lib - path: ..
target: /home/container
action: sync
env_file: .env.development env_file: .env.development
ports: ports:
- 3000:3000 - 3000:3000
+4
View File
@@ -158,6 +158,10 @@ export const endgameArchive = endgame.table("sarchive", {
price: integer().notNull(), price: integer().notNull(),
slip: text().references(() => endgameSlips.id), slip: text().references(() => endgameSlips.id),
round: integer().notNull(), round: integer().notNull(),
service: text()
.references(() => endgameTypes.id)
.array()
.notNull(),
}); });
// expired submission // expired submission
+10
View File
@@ -170,3 +170,13 @@ export function shortestPrefixes(values: readonly string[]) {
return res; return res;
} }
export function parseParamNumber(param: string | string[] | undefined) {
if (Array.isArray(param)) {
if (param.length < 1) return 0;
return parseParamNumber(param[0]);
}
if (!param) return 0;
const int = parseInt(param, 10);
if (Number.isNaN(int)) return 0;
return int;
}