Admin done

This commit is contained in:
Dreamgineer
2025-09-24 03:59:20 -04:00 Unverified
parent 639e83eaeb
commit f4307e0090
14 changed files with 417 additions and 83 deletions
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { Copy, CopyCheck, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function EnkaBrowser({ uid }: { uid: string }) {
const [id, setId] = useState(uid);
const [ready, setReady] = useState(false);
useEffect(() => {
setId(uid);
setReady(false);
}, [uid]);
return (
<div className="h-full w-full relative">
{!ready && (
<Loader2 className="animate-spin absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-8 text-muted-foreground z-100" />
)}
<iframe
src={`https://enka.network/u/${id}`}
className={cn(
"w-full border-0 bg-card -mt-20 h-[calc(100%+5rem)]",
!ready && "grayscale blur-md brightness-50 pointer-events-none",
)}
title="Enka Network"
onLoad={() => setReady(true)}
/>
</div>
);
}
export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
if (copied) return;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Button variant="ghost" size="icon" onClick={copy} disabled={copied}>
{copied ? <CopyCheck /> : <Copy />}
</Button>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { eq } from "drizzle-orm";
import Avatar from "@/components/avatar";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { db } from "@/lib/db";
import { characters, submissions } from "@/lib/db/schema";
import { CopyButton, EnkaBrowser } from "./client";
export const revalidate = 86400; // 24 hours
export async function generateStaticParams() {
return await db.select({ id: submissions.id }).from(submissions);
}
export default async function AdminSubmissionView({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [sub] = await db
.select()
.from(submissions)
.where(eq(submissions.id, id))
.limit(1);
const [char] = await db
.select()
.from(characters)
.where(eq(characters.name, sub?.char || ""))
.limit(1);
return (
<div className="p-2 h-full">
<div className="flex flex-col h-full w-full gap-2">
<div className="flex w-full justify-between gap-2">
<Card className="w-full pb-1">
<CardHeader>
<CardTitle>
{sub.queue}. {sub.name}
</CardTitle>
<CardDescription>
{sub.comment}
</CardDescription>
</CardHeader>
<CardFooter className="h-full items-end">
<div className="flex items-center">
<span className="text-gray-500">{sub.uid}</span>
<CopyButton text={sub.uid} />
</div>
</CardFooter>
</Card>
<Avatar char={char} />
</div>
<div className="border rounded-md h-full overflow-hidden">
<EnkaBrowser uid={sub?.uid || ""} />
</div>
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { Lock, Unlock } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { setLimit, toggleCheck, toggleLock } from "@/lib/api";
import { cn } from "@/lib/utils";
export function SidebarLink({
submission,
className,
}: {
submission: { id: string; name: string; checked: boolean; queue: number };
className?: string;
}) {
const { id } = useParams();
return (
<Link
href={`/artifact/admin/${submission.id}`}
className={cn(
className,
id === submission.id && "bg-accent text-accent-foreground",
)}
>
{submission.queue}. {submission.name}
<SidebarMenuBadge>
<Checkbox
className="mr-2 z-500"
checked={submission.checked}
onClick={() => toggleCheck(submission.id)}
/>
</SidebarMenuBadge>
</Link>
);
}
export function LimitManager({
config,
length,
...props
}: React.ComponentProps<typeof SidebarMenu> & {
config: Awaited<ReturnType<typeof import("@/lib/api").getArtifactConfig>>;
length: number;
}) {
return (
<SidebarMenu {...props}>
<SidebarMenuItem>
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton>
<Tooltip>
{config.locked ? (
<>
<TooltipTrigger asChild>
<Lock className="text-red-500" />
</TooltipTrigger>
<TooltipContent>
<p>ปิดรับ</p>
</TooltipContent>
</>
) : config.limit >= 0 && length > config.limit ? (
<>
<TooltipTrigger asChild>
<Lock className="text-orange-400" />
</TooltipTrigger>
<TooltipContent>
<p>เต็มแล้ว</p>
</TooltipContent>
</>
) : (
<>
<TooltipTrigger asChild>
<Unlock className="text-sky-400" />
</TooltipTrigger>
<TooltipContent>
<p>เปิดรับ</p>
</TooltipContent>
</>
)}
</Tooltip>{" "}
จำนวน
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="end">
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<span>ตั้ง Limit</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Set limit</DialogTitle>
</DialogHeader>
<form
id="limit-form"
action={async (data: FormData) =>
setLimit(Number(data.get("limit")) || -1)
}
>
<Input
id="limit"
name="limit"
type="number"
placeholder="ไม่จำกัด"
defaultValue={config.limit >= 0 ? config.limit : ""}
/>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button type="submit" form="limit-form">
Save changes
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
<DropdownMenuItem onClick={toggleLock}>
<span>{config.locked && "เ"}ปิดรับ</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Dialog>
<SidebarMenuBadge>
{length}
{config.limit >= 0 ? `/${config.limit}` : ""}
</SidebarMenuBadge>
</SidebarMenuItem>
</SidebarMenu>
);
}
+53 -70
View File
@@ -1,94 +1,77 @@
import { Dice3, Lock, Trash2 } from "lucide-react";
import { Dice3, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarProvider,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getArtifactConfig } from "@/lib/api";
import { db } from "@/lib/db";
import { submissions } from "@/lib/db/schema";
import { LimitManager, SidebarLink } from "./client";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const subs = await db.select().from(submissions);
const subs = await db
.select({
id: submissions.id,
name: submissions.name,
checked: submissions.checked,
queue: submissions.queue,
})
.from(submissions)
.orderBy(submissions.id);
const config = await getArtifactConfig();
return (
<>
<SidebarProvider>
<Sidebar>
<SidebarHeader>
<div className="flex justify-between items-center">
<b>Admin</b>
<div className="flex gap-1">
<Button variant="outline" size="icon" className="size-8">
<Dice3 size={24} className="size-6" />
</Button>
<Button variant="outline" size="icon" className="size-8">
<Trash2 size={24} className="size-6" />
</Button>
</div>
<SidebarProvider>
<Sidebar>
<SidebarHeader>
<div className="flex justify-between items-center">
<b>Admin</b>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="size-8">
<Dice3 size={24} className="size-6" />
</Button>
<Button variant="ghost" size="icon" className="size-8">
<Trash2 size={24} className="size-6 text-red-500" />
</Button>
</div>
<Input placeholder="Search..." />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Submissions</SidebarGroupLabel>
<SidebarMenu>
{subs.map((s) => (
<SidebarMenuButton key={s.id}>
{s.name}
<SidebarMenuBadge>
<Checkbox className="mr-2" />
</SidebarMenuBadge>
</SidebarMenuButton>
))}
{Array.from({ length: 5 }).map((_, index) => (
<SidebarMenuItem key={index.toString()}>
<SidebarMenuSkeleton />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup className="mt-auto">
<SidebarGroupLabel>จำนวน</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>
<Tooltip>
<TooltipTrigger asChild>
<Lock />
</TooltipTrigger>
<TooltipContent>
<p>Submissions are currently locked</p>
</TooltipContent>
</Tooltip>{" "}
จำนวน
</SidebarMenuButton>
<SidebarMenuBadge>5/15</SidebarMenuBadge>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
{children}
</>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Submissions</SidebarGroupLabel>
<SidebarMenu>
<form>
<Input placeholder="Search..." type="search" name="q" />
</form>
{subs.map((s) => (
<SidebarMenuButton key={s.id} asChild>
<SidebarLink submission={s} />
</SidebarMenuButton>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarGroup>
<SidebarGroupLabel>การตั้งค่า</SidebarGroupLabel>
<LimitManager config={config} length={subs.length} />
</SidebarGroup>
</SidebarFooter>
</Sidebar>
<SidebarInset className="bg-transparent">{children}</SidebarInset>
</SidebarProvider>
);
}
+1
View File
@@ -56,6 +56,7 @@ export function CharacterChooser() {
onChange={(ev) => setUid(ev.target.value)}
/>
</div>
<Label htmlFor="character">เลือกตัวละครที่ต้องการ</Label>
<ScrollArea>
{isError ? (
<Placeholder>
+4 -1
View File
@@ -91,6 +91,7 @@ export default async function ArtifactFormPage() {
type="text"
placeholder="Mr. Buzz"
autoComplete="name"
maxLength={32}
required
/>
</div>
@@ -106,6 +107,7 @@ export default async function ArtifactFormPage() {
type="number"
required
placeholder="887654321"
maxLength={10}
/>
</div>
<div className="grid gap-2">
@@ -132,6 +134,7 @@ export default async function ArtifactFormPage() {
name="comment"
placeholder="(ไม่บังคับ)"
className="bg-card"
maxLength={512}
/>
</div>
</div>
@@ -189,7 +192,7 @@ export default async function ArtifactFormPage() {
<SendHorizonal />
</Button>
</TooltipTrigger>
<TooltipContent>ส่งเลยคับพี่</TooltipContent>
<TooltipContent>ส่งเลยจัฟลูกพี่</TooltipContent>
</Tooltip>
</CardFooter>
</Card>
+2
View File
@@ -3,6 +3,8 @@ import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { cdn } from "@/lib/db/schema";
export const revalidate = 86400; // 24 hours
export async function generateStaticParams() {
return await db.select({ id: cdn.id }).from(cdn);
}
+8 -5
View File
@@ -10,15 +10,15 @@ import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
export default function LoginPage({
queryParams,
searchParams,
}: {
queryParams: Promise<{ next?: string }>;
searchParams: Promise<{ next?: string }>;
}) {
const router = useRouter();
async function submit(ev: FormEvent<HTMLFormElement>) {
ev.preventDefault();
const data = new FormData(ev.currentTarget);
const cb = (await queryParams?.then(({ next }) => next)) || "/admin";
const cb = (await searchParams?.then(({ next }) => next)) || "/admin";
const r = await authClient.signIn
.email({
email: data.get("email") as string,
@@ -49,8 +49,11 @@ export default function LoginPage({
<h1 className="text-xl font-bold">Welcome to Buzzy.</h1>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a href="/" className="underline underline-offset-4">
Get out
<a
href="https://cdn.dgnr.us/signup"
className="underline underline-offset-4"
>
Sign Up
</a>
</div>
</div>
+8 -1
View File
@@ -7,7 +7,14 @@
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
"includes": [
"**",
"!node_modules",
"!.next",
"!dist",
"!build",
"!components/ui/"
]
},
"formatter": {
"enabled": true,
+1
View File
@@ -1,3 +1,4 @@
"use client";
import { AutoTextSize } from "auto-text-size";
import Image from "next/image";
import type { characters } from "@/lib/db/schema";
+47 -2
View File
@@ -1,7 +1,8 @@
"use server";
import { eq, inArray } from "drizzle-orm";
import { eq, inArray, not } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { apiAuthCheck } from "./auth";
import { uidRegex } from "./const";
import { db } from "./db";
import {
@@ -34,7 +35,7 @@ export async function submitArtifact(formData: FormData) {
if (!name || !uid || !character) throw "กรุณากรอกข้อมูลให้ครบถ้วน";
if (name.length > 32) throw "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร";
if (!uidRegex.test(uid)) throw "UID ไม่ถูกต้อง ต้องเป็นเลข 9 หลัก";
if (comment.length > 256) throw "ข้อความเพิ่มเติมยาวเกินไป ต้องไม่เกิน 256 ตัวอักษร";
if (comment.length > 256) throw "ข้อความเพิ่มเติมยาวเกินไป ต้องไม่เกิน 512 ตัวอักษร";
if (config.locked) throw "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก";
const count = await db.$count(submissions);
if (config.limit !== -1 && count >= config.limit)
@@ -63,3 +64,47 @@ export async function submitArtifact(formData: FormData) {
revalidatePath("/artifact/admin");
return queue;
}
export async function toggleCheck(submissionId: string) {
if (!(await apiAuthCheck())) throw "Unauthorized";
await db
.update(submissions)
.set({
checked: not(submissions.checked),
})
.where(eq(submissions.id, submissionId));
revalidatePath("/artifact/admin");
}
export async function toggleLock() {
if (!(await apiAuthCheck())) throw "Unauthorized";
if (
(
await db
.update(artifactSettings)
.set({
locked: not(artifactSettings.locked),
})
.returning({ id: artifactSettings.id })
).length === 0
)
await db.insert(artifactSettings).values({ locked: true });
revalidatePath("/artifact/admin");
}
export async function setLimit(limit: number) {
if (!(await apiAuthCheck())) throw "Unauthorized";
if (
(
await db
.update(artifactSettings)
.set({
limit,
})
.returning({ id: artifactSettings.id })
).length === 0
)
await db.insert(artifactSettings).values({ limit });
revalidatePath("/artifact/admin");
}
+13 -2
View File
@@ -1,5 +1,6 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { headers } from "next/headers";
import { db } from "$/db"; // your drizzle instance
import * as schema from "$/db/schema";
@@ -9,6 +10,16 @@ export const auth = betterAuth({
},
database: drizzleAdapter(db, {
provider: "pg", // or "mysql", "sqlite"
schema
schema,
}),
});
});
export async function apiAuthCheck() {
"use server";
const session = await auth.api.getSession({
headers: await headers(),
});
return session.user;
}
+1
View File
@@ -78,6 +78,7 @@ export const submissions = artifact.table("submissions", {
.notNull()
.references(() => characters.name),
queue: serial(),
checked: boolean().notNull().default(false),
});
export const artifactSettings = artifact.table("settings", {
+7 -2
View File
@@ -8,12 +8,17 @@ export async function middleware(request: NextRequest) {
// This is the recommended approach to optimistically redirect users
// We recommend handling auth checks in each page/route
if (!sessionCookie) {
return NextResponse.redirect(new URL("/auth", request.url));
const url = new URL("/login", request.url);
url.searchParams.set(
"next",
request.nextUrl.pathname + request.nextUrl.search,
);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/manager/:path*"], // Specify the routes the middleware applies to
matcher: ["/artifact/admin/:path*"], // Specify the routes the middleware applies to
};