feat: add admin slip management pages and update navbar styles
This commit is contained in:
+8
-3
@@ -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"}`);
|
||||||
|
}
|
||||||
@@ -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
@@ -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,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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user